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:
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -1489,7 +1489,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "egui_nav"
|
name = "egui_nav"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"egui",
|
"egui",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
@@ -1554,6 +1554,7 @@ version = "0.3.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bech32",
|
"bech32",
|
||||||
"ewebsock",
|
"ewebsock",
|
||||||
|
"hashbrown",
|
||||||
"hex",
|
"hex",
|
||||||
"mio",
|
"mio",
|
||||||
"nostr 0.37.0",
|
"nostr 0.37.0",
|
||||||
@@ -3302,6 +3303,7 @@ dependencies = [
|
|||||||
"egui_virtual_list",
|
"egui_virtual_list",
|
||||||
"ehttp",
|
"ehttp",
|
||||||
"enostr",
|
"enostr",
|
||||||
|
"hashbrown",
|
||||||
"hex",
|
"hex",
|
||||||
"human_format",
|
"human_format",
|
||||||
"image",
|
"image",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ egui = { version = "0.31.1", features = ["serde"] }
|
|||||||
egui-wgpu = "0.31.1"
|
egui-wgpu = "0.31.1"
|
||||||
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
|
egui_extras = { version = "0.31.1", features = ["all_loaders"] }
|
||||||
egui-winit = { version = "0.31.1", features = ["android-game-activity", "clipboard"] }
|
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_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "6eb91740577b374a8a6658c09c9a4181299734d0" }
|
||||||
#egui_virtual_list = "0.6.0"
|
#egui_virtual_list = "0.6.0"
|
||||||
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
|
egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b6794f5e707a2f4109633770e02b02fb722e1" }
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ url = { workspace = true }
|
|||||||
mio = { workspace = true }
|
mio = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tokenator = { workspace = true }
|
tokenator = { workspace = true }
|
||||||
|
hashbrown = { workspace = true }
|
||||||
@@ -143,3 +143,9 @@ impl<'de> Deserialize<'de> for NoteId {
|
|||||||
NoteId::from_hex(&s).map_err(serde::de::Error::custom)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub enum NoteAction {
|
|||||||
Profile(Pubkey),
|
Profile(Pubkey),
|
||||||
|
|
||||||
/// User has clicked a note link
|
/// User has clicked a note link
|
||||||
Note(NoteId),
|
Note { note_id: NoteId, preview: bool },
|
||||||
|
|
||||||
/// User has selected some context option
|
/// User has selected some context option
|
||||||
Context(ContextSelection),
|
Context(ContextSelection),
|
||||||
@@ -30,6 +30,15 @@ pub enum NoteAction {
|
|||||||
Media(MediaAction),
|
Media(MediaAction),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl NoteAction {
|
||||||
|
pub fn note(id: NoteId) -> NoteAction {
|
||||||
|
NoteAction::Note {
|
||||||
|
note_id: id,
|
||||||
|
preview: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone)]
|
#[derive(Debug, Eq, PartialEq, Clone)]
|
||||||
pub enum ZapAction {
|
pub enum ZapAction {
|
||||||
Send(ZapTargetAmount),
|
Send(ZapTargetAmount),
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ impl UnknownIds {
|
|||||||
pub fn add_unknown_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, unk_id: &UnknownId) {
|
pub fn add_unknown_id_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, unk_id: &UnknownId) {
|
||||||
match unk_id {
|
match unk_id {
|
||||||
UnknownId::Pubkey(pk) => self.add_pubkey_if_missing(ndb, txn, pk),
|
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();
|
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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.ids.entry(UnknownId::Id(*note_id)).or_default();
|
self.ids
|
||||||
|
.entry(UnknownId::Id(NoteId::new(*note_id)))
|
||||||
|
.or_default();
|
||||||
self.mark_updated();
|
self.mark_updated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,6 +625,7 @@ fn chrome_handle_app_action(
|
|||||||
cols,
|
cols,
|
||||||
0,
|
0,
|
||||||
&mut columns.timeline_cache,
|
&mut columns.timeline_cache,
|
||||||
|
&mut columns.threads,
|
||||||
ctx.note_cache,
|
ctx.note_cache,
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
&txn,
|
&txn,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ sha2 = { workspace = true }
|
|||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
egui-winit = { workspace = true }
|
egui-winit = { workspace = true }
|
||||||
profiling = { workspace = true }
|
profiling = { workspace = true }
|
||||||
|
hashbrown = { workspace = true }
|
||||||
human_format = "1.1.0"
|
human_format = "1.1.0"
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
|
[target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies]
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ use crate::{
|
|||||||
column::Columns,
|
column::Columns,
|
||||||
nav::{RouterAction, RouterType},
|
nav::{RouterAction, RouterType},
|
||||||
route::Route,
|
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 nostrdb::{Ndb, NoteKey, Transaction};
|
||||||
use notedeck::{
|
use notedeck::{
|
||||||
get_wallet_for_mut, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction,
|
get_wallet_for_mut, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction,
|
||||||
@@ -18,12 +23,17 @@ pub struct NewNotes {
|
|||||||
pub notes: Vec<NoteKey>,
|
pub notes: Vec<NoteKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub enum NotesOpenResult {
|
||||||
|
Timeline(TimelineOpenResult),
|
||||||
|
Thread(NewThreadNotes),
|
||||||
|
}
|
||||||
|
|
||||||
pub enum TimelineOpenResult {
|
pub enum TimelineOpenResult {
|
||||||
NewNotes(NewNotes),
|
NewNotes(NewNotes),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NoteActionResponse {
|
struct NoteActionResponse {
|
||||||
timeline_res: Option<TimelineOpenResult>,
|
timeline_res: Option<NotesOpenResult>,
|
||||||
router_action: Option<RouterAction>,
|
router_action: Option<RouterAction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,8 +41,9 @@ struct NoteActionResponse {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn execute_note_action(
|
fn execute_note_action(
|
||||||
action: NoteAction,
|
action: NoteAction,
|
||||||
ndb: &Ndb,
|
ndb: &mut Ndb,
|
||||||
timeline_cache: &mut TimelineCache,
|
timeline_cache: &mut TimelineCache,
|
||||||
|
threads: &mut Threads,
|
||||||
note_cache: &mut NoteCache,
|
note_cache: &mut NoteCache,
|
||||||
pool: &mut RelayPool,
|
pool: &mut RelayPool,
|
||||||
txn: &Transaction,
|
txn: &Transaction,
|
||||||
@@ -42,6 +53,7 @@ fn execute_note_action(
|
|||||||
images: &mut Images,
|
images: &mut Images,
|
||||||
router_type: RouterType,
|
router_type: RouterType,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
col: usize,
|
||||||
) -> NoteActionResponse {
|
) -> NoteActionResponse {
|
||||||
let mut timeline_res = None;
|
let mut timeline_res = None;
|
||||||
let mut router_action = None;
|
let mut router_action = None;
|
||||||
@@ -53,25 +65,34 @@ fn execute_note_action(
|
|||||||
NoteAction::Profile(pubkey) => {
|
NoteAction::Profile(pubkey) => {
|
||||||
let kind = TimelineKind::Profile(pubkey);
|
let kind = TimelineKind::Profile(pubkey);
|
||||||
router_action = Some(RouterAction::route_to(Route::Timeline(kind.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::Note(note_id) => 'ex: {
|
NoteAction::Note { note_id, preview } => 'ex: {
|
||||||
let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
|
let Ok(thread_selection) = ThreadSelection::from_note_id(ndb, note_cache, txn, note_id)
|
||||||
else {
|
else {
|
||||||
tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
|
tracing::error!("No thread selection for {}?", hex::encode(note_id.bytes()));
|
||||||
break 'ex;
|
break 'ex;
|
||||||
};
|
};
|
||||||
|
|
||||||
let kind = TimelineKind::Thread(thread_selection);
|
timeline_res = threads
|
||||||
router_action = Some(RouterAction::route_to(Route::Timeline(kind.clone())));
|
.open(ndb, txn, pool, &thread_selection, preview, col)
|
||||||
// NOTE!!: you need the note_id to timeline root id thing
|
.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) => {
|
NoteAction::Hashtag(htag) => {
|
||||||
let kind = TimelineKind::Hashtag(htag.clone());
|
let kind = TimelineKind::Hashtag(htag.clone());
|
||||||
router_action = Some(RouterAction::route_to(Route::Timeline(kind.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) => {
|
NoteAction::Quote(note_id) => {
|
||||||
router_action = Some(RouterAction::route_to(Route::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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn execute_and_process_note_action(
|
pub fn execute_and_process_note_action(
|
||||||
action: NoteAction,
|
action: NoteAction,
|
||||||
ndb: &Ndb,
|
ndb: &mut Ndb,
|
||||||
columns: &mut Columns,
|
columns: &mut Columns,
|
||||||
col: usize,
|
col: usize,
|
||||||
timeline_cache: &mut TimelineCache,
|
timeline_cache: &mut TimelineCache,
|
||||||
|
threads: &mut Threads,
|
||||||
note_cache: &mut NoteCache,
|
note_cache: &mut NoteCache,
|
||||||
pool: &mut RelayPool,
|
pool: &mut RelayPool,
|
||||||
txn: &Transaction,
|
txn: &Transaction,
|
||||||
@@ -163,6 +185,7 @@ pub fn execute_and_process_note_action(
|
|||||||
action,
|
action,
|
||||||
ndb,
|
ndb,
|
||||||
timeline_cache,
|
timeline_cache,
|
||||||
|
threads,
|
||||||
note_cache,
|
note_cache,
|
||||||
pool,
|
pool,
|
||||||
txn,
|
txn,
|
||||||
@@ -172,10 +195,18 @@ pub fn execute_and_process_note_action(
|
|||||||
images,
|
images,
|
||||||
router_type,
|
router_type,
|
||||||
ui,
|
ui,
|
||||||
|
col,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(br) = resp.timeline_res {
|
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
|
resp.router_action
|
||||||
@@ -237,7 +268,7 @@ impl NewNotes {
|
|||||||
unknown_ids: &mut UnknownIds,
|
unknown_ids: &mut UnknownIds,
|
||||||
note_cache: &mut NoteCache,
|
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) {
|
let timeline = if let Some(profile) = timeline_cache.timelines.get_mut(&self.id) {
|
||||||
profile
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
storage,
|
storage,
|
||||||
subscriptions::{SubKind, Subscriptions},
|
subscriptions::{SubKind, Subscriptions},
|
||||||
support::Support,
|
support::Support,
|
||||||
timeline::{self, TimelineCache},
|
timeline::{self, thread::Threads, TimelineCache},
|
||||||
ui::{self, DesktopSidePanel},
|
ui::{self, DesktopSidePanel},
|
||||||
view_state::ViewState,
|
view_state::ViewState,
|
||||||
Result,
|
Result,
|
||||||
@@ -45,6 +45,7 @@ pub struct Damus {
|
|||||||
pub subscriptions: Subscriptions,
|
pub subscriptions: Subscriptions,
|
||||||
pub support: Support,
|
pub support: Support,
|
||||||
pub jobs: JobsCache,
|
pub jobs: JobsCache,
|
||||||
|
pub threads: Threads,
|
||||||
|
|
||||||
//frame_history: crate::frame_history::FrameHistory,
|
//frame_history: crate::frame_history::FrameHistory,
|
||||||
|
|
||||||
@@ -443,6 +444,8 @@ impl Damus {
|
|||||||
|
|
||||||
ctx.accounts.with_fallback(FALLBACK_PUBKEY());
|
ctx.accounts.with_fallback(FALLBACK_PUBKEY());
|
||||||
|
|
||||||
|
let threads = Threads::default();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
subscriptions: Subscriptions::default(),
|
subscriptions: Subscriptions::default(),
|
||||||
since_optimize: parsed_args.since_optimize,
|
since_optimize: parsed_args.since_optimize,
|
||||||
@@ -458,6 +461,7 @@ impl Damus {
|
|||||||
debug,
|
debug,
|
||||||
unrecognized_args,
|
unrecognized_args,
|
||||||
jobs,
|
jobs,
|
||||||
|
threads,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +506,7 @@ impl Damus {
|
|||||||
decks_cache,
|
decks_cache,
|
||||||
unrecognized_args: BTreeSet::default(),
|
unrecognized_args: BTreeSet::default(),
|
||||||
jobs: JobsCache::default(),
|
jobs: JobsCache::default(),
|
||||||
|
threads: Threads::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 nostrdb::{Ndb, Subscription};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::timeline::ThreadSelection;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct MultiSubscriber {
|
pub struct MultiSubscriber {
|
||||||
pub filters: Vec<Filter>,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use crate::{
|
|||||||
profile_state::ProfileState,
|
profile_state::ProfileState,
|
||||||
relay_pool_manager::RelayPoolManager,
|
relay_pool_manager::RelayPoolManager,
|
||||||
route::{Route, Router, SingletonRouter},
|
route::{Route, Router, SingletonRouter},
|
||||||
timeline::{route::render_timeline_route, TimelineCache},
|
timeline::{
|
||||||
|
route::{render_thread_route, render_timeline_route},
|
||||||
|
TimelineCache,
|
||||||
|
},
|
||||||
ui::{
|
ui::{
|
||||||
self,
|
self,
|
||||||
add_column::render_add_column_routes,
|
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);
|
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);
|
let column = app.columns_mut(ctx.accounts).column_mut(col);
|
||||||
column.sheet_router.clear();
|
column.sheet_router.clear();
|
||||||
} else if let Some(NavAction::Navigating) = action.action {
|
} else if let Some(NavAction::Navigating) = action.action {
|
||||||
@@ -210,7 +213,7 @@ fn process_nav_resp(
|
|||||||
|
|
||||||
if let Some(action) = response.action {
|
if let Some(action) = response.action {
|
||||||
match action {
|
match action {
|
||||||
NavAction::Returned => {
|
NavAction::Returned(return_type) => {
|
||||||
let r = app
|
let r = app
|
||||||
.columns_mut(ctx.accounts)
|
.columns_mut(ctx.accounts)
|
||||||
.column_mut(col)
|
.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);
|
process_result = Some(ProcessNavResult::SwitchOccurred);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +246,7 @@ fn process_nav_resp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
NavAction::Dragging => {}
|
NavAction::Dragging => {}
|
||||||
NavAction::Returning => {}
|
NavAction::Returning(_) => {}
|
||||||
NavAction::Resetting => {}
|
NavAction::Resetting => {}
|
||||||
NavAction::Navigating => {}
|
NavAction::Navigating => {}
|
||||||
}
|
}
|
||||||
@@ -253,6 +262,10 @@ pub enum RouterAction {
|
|||||||
/// chrome atm
|
/// chrome atm
|
||||||
PfpClicked,
|
PfpClicked,
|
||||||
RouteTo(Route, RouterType),
|
RouteTo(Route, RouterType),
|
||||||
|
Overlay {
|
||||||
|
route: Route,
|
||||||
|
make_new: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum RouterType {
|
pub enum RouterType {
|
||||||
@@ -289,6 +302,14 @@ impl RouterAction {
|
|||||||
None
|
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),
|
get_active_columns_mut(ctx.accounts, &mut app.decks_cache),
|
||||||
col,
|
col,
|
||||||
&mut app.timeline_cache,
|
&mut app.timeline_cache,
|
||||||
|
&mut app.threads,
|
||||||
ctx.note_cache,
|
ctx.note_cache,
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
&txn,
|
&txn,
|
||||||
@@ -414,6 +436,17 @@ fn render_nav_body(
|
|||||||
&mut note_context,
|
&mut note_context,
|
||||||
&mut app.jobs,
|
&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) => {
|
Route::Accounts(amr) => {
|
||||||
let mut action = render_accounts_route(
|
let mut action = render_accounts_route(
|
||||||
ui,
|
ui,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use enostr::{NoteId, Pubkey};
|
use enostr::{NoteId, Pubkey};
|
||||||
use notedeck::{NoteZapTargetOwned, WalletType};
|
use notedeck::{NoteZapTargetOwned, RootNoteIdBuf, WalletType};
|
||||||
use std::fmt::{self};
|
use std::{
|
||||||
|
fmt::{self},
|
||||||
|
ops::Range,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
accounts::AccountsRoute,
|
accounts::AccountsRoute,
|
||||||
@@ -17,6 +20,7 @@ use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter};
|
|||||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
Timeline(TimelineKind),
|
Timeline(TimelineKind),
|
||||||
|
Thread(ThreadSelection),
|
||||||
Accounts(AccountsRoute),
|
Accounts(AccountsRoute),
|
||||||
Reply(NoteId),
|
Reply(NoteId),
|
||||||
Quote(NoteId),
|
Quote(NoteId),
|
||||||
@@ -50,7 +54,7 @@ impl Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn thread(thread_selection: ThreadSelection) -> Self {
|
pub fn thread(thread_selection: ThreadSelection) -> Self {
|
||||||
Route::Timeline(TimelineKind::Thread(thread_selection))
|
Route::Thread(thread_selection)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile(pubkey: Pubkey) -> Self {
|
pub fn profile(pubkey: Pubkey) -> Self {
|
||||||
@@ -76,6 +80,18 @@ impl Route {
|
|||||||
pub fn serialize_tokens(&self, writer: &mut TokenWriter) {
|
pub fn serialize_tokens(&self, writer: &mut TokenWriter) {
|
||||||
match self {
|
match self {
|
||||||
Route::Timeline(timeline_kind) => timeline_kind.serialize_tokens(writer),
|
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::Accounts(routes) => routes.serialize_tokens(writer),
|
||||||
Route::AddColumn(routes) => routes.serialize_tokens(writer),
|
Route::AddColumn(routes) => routes.serialize_tokens(writer),
|
||||||
Route::Search => writer.write_token("search"),
|
Route::Search => writer.write_token("search"),
|
||||||
@@ -196,6 +212,31 @@ impl Route {
|
|||||||
Ok(Route::Search)
|
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<'_> {
|
pub fn title(&self) -> ColumnTitle<'_> {
|
||||||
match self {
|
match self {
|
||||||
Route::Timeline(kind) => kind.to_title(),
|
Route::Timeline(kind) => kind.to_title(),
|
||||||
|
Route::Thread(_) => ColumnTitle::simple("Thread"),
|
||||||
Route::Reply(_id) => ColumnTitle::simple("Reply"),
|
Route::Reply(_id) => ColumnTitle::simple("Reply"),
|
||||||
Route::Quote(_id) => ColumnTitle::simple("Quote"),
|
Route::Quote(_id) => ColumnTitle::simple("Quote"),
|
||||||
Route::Relays => ColumnTitle::simple("Relays"),
|
Route::Relays => ColumnTitle::simple("Relays"),
|
||||||
@@ -250,6 +292,9 @@ pub struct Router<R: Clone> {
|
|||||||
pub returning: bool,
|
pub returning: bool,
|
||||||
pub navigating: bool,
|
pub navigating: bool,
|
||||||
replacing: 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> {
|
impl<R: Clone> Router<R> {
|
||||||
@@ -265,6 +310,7 @@ impl<R: Clone> Router<R> {
|
|||||||
returning,
|
returning,
|
||||||
navigating,
|
navigating,
|
||||||
replacing,
|
replacing,
|
||||||
|
overlay_ranges: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +319,16 @@ impl<R: Clone> Router<R> {
|
|||||||
self.routes.push(route);
|
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
|
// 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) {
|
pub fn route_to_replaced(&mut self, route: R) {
|
||||||
self.navigating = true;
|
self.navigating = true;
|
||||||
@@ -286,6 +342,18 @@ impl<R: Clone> Router<R> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.returning = true;
|
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()
|
self.prev().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +362,24 @@ impl<R: Clone> Router<R> {
|
|||||||
if self.routes.len() == 1 {
|
if self.routes.len() == 1 {
|
||||||
return None;
|
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.returning = false;
|
||||||
self.routes.pop()
|
self.routes.pop()
|
||||||
}
|
}
|
||||||
@@ -309,10 +395,47 @@ impl<R: Clone> Router<R> {
|
|||||||
self.routes.drain(..num_routes - 1);
|
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 {
|
pub fn is_replacing(&self) -> bool {
|
||||||
self.replacing
|
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 {
|
pub fn top(&self) -> &R {
|
||||||
self.routes.last().expect("routes can't be empty")
|
self.routes.last().expect("routes can't be empty")
|
||||||
}
|
}
|
||||||
@@ -339,9 +462,9 @@ impl fmt::Display for Route {
|
|||||||
TimelineKind::Generic(_) => write!(f, "Custom"),
|
TimelineKind::Generic(_) => write!(f, "Custom"),
|
||||||
TimelineKind::Search(_) => write!(f, "Search"),
|
TimelineKind::Search(_) => write!(f, "Search"),
|
||||||
TimelineKind::Hashtag(ht) => write!(f, "Hashtag ({})", ht),
|
TimelineKind::Hashtag(ht) => write!(f, "Hashtag ({})", ht),
|
||||||
TimelineKind::Thread(_id) => write!(f, "Thread"),
|
|
||||||
TimelineKind::Profile(_id) => write!(f, "Profile"),
|
TimelineKind::Profile(_id) => write!(f, "Profile"),
|
||||||
},
|
},
|
||||||
|
Route::Thread(_) => write!(f, "Thread"),
|
||||||
Route::Reply(_id) => write!(f, "Reply"),
|
Route::Reply(_id) => write!(f, "Reply"),
|
||||||
Route::Quote(_id) => write!(f, "Quote"),
|
Route::Quote(_id) => write!(f, "Quote"),
|
||||||
Route::Relays => write!(f, "Relays"),
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,16 +319,17 @@ fn deserialize_columns(
|
|||||||
) -> Columns {
|
) -> Columns {
|
||||||
let mut cols = Columns::new();
|
let mut cols = Columns::new();
|
||||||
for column in columns {
|
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 tokens: Vec<&str> = route.split(":").collect();
|
||||||
let mut parser = TokenParser::new(&tokens);
|
let mut parser = TokenParser::new(&tokens);
|
||||||
|
|
||||||
match CleanIntermediaryRoute::parse(&mut parser, deck_user) {
|
match CleanIntermediaryRoute::parse(&mut parser, deck_user) {
|
||||||
Ok(route_intermediary) => {
|
Ok(route_intermediary) => {
|
||||||
if let Some(ir) = route_intermediary.into_intermediary_route(ndb) {
|
if let Some(ir) = route_intermediary.into_intermediary_route(ndb) {
|
||||||
cur_routes.push(ir);
|
cols.insert_intermediary_routes(timeline_cache, vec![ir]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -337,11 +338,6 @@ fn deserialize_columns(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cur_routes.is_empty() {
|
|
||||||
cols.insert_intermediary_routes(timeline_cache, cur_routes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cols
|
cols
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,8 +208,6 @@ pub enum TimelineKind {
|
|||||||
|
|
||||||
Profile(Pubkey),
|
Profile(Pubkey),
|
||||||
|
|
||||||
Thread(ThreadSelection),
|
|
||||||
|
|
||||||
Universe,
|
Universe,
|
||||||
|
|
||||||
/// Generic filter, references a hash of a filter
|
/// Generic filter, references a hash of a filter
|
||||||
@@ -266,7 +264,6 @@ impl Display for TimelineKind {
|
|||||||
TimelineKind::Profile(_) => f.write_str("Profile"),
|
TimelineKind::Profile(_) => f.write_str("Profile"),
|
||||||
TimelineKind::Universe => f.write_str("Universe"),
|
TimelineKind::Universe => f.write_str("Universe"),
|
||||||
TimelineKind::Hashtag(_) => f.write_str("Hashtag"),
|
TimelineKind::Hashtag(_) => f.write_str("Hashtag"),
|
||||||
TimelineKind::Thread(_) => f.write_str("Thread"),
|
|
||||||
TimelineKind::Search(_) => f.write_str("Search"),
|
TimelineKind::Search(_) => f.write_str("Search"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +279,6 @@ impl TimelineKind {
|
|||||||
TimelineKind::Universe => None,
|
TimelineKind::Universe => None,
|
||||||
TimelineKind::Generic(_) => None,
|
TimelineKind::Generic(_) => None,
|
||||||
TimelineKind::Hashtag(_ht) => None,
|
TimelineKind::Hashtag(_ht) => None,
|
||||||
TimelineKind::Thread(_ht) => None,
|
|
||||||
TimelineKind::Search(query) => query.author(),
|
TimelineKind::Search(query) => query.author(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +294,6 @@ impl TimelineKind {
|
|||||||
TimelineKind::Universe => true,
|
TimelineKind::Universe => true,
|
||||||
TimelineKind::Generic(_) => true,
|
TimelineKind::Generic(_) => true,
|
||||||
TimelineKind::Hashtag(_ht) => true,
|
TimelineKind::Hashtag(_ht) => true,
|
||||||
TimelineKind::Thread(_ht) => true,
|
|
||||||
TimelineKind::Search(_q) => true,
|
TimelineKind::Search(_q) => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,10 +316,6 @@ impl TimelineKind {
|
|||||||
writer.write_token("profile");
|
writer.write_token("profile");
|
||||||
PubkeySource::pubkey(*pk).serialize_tokens(writer);
|
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 => {
|
TimelineKind::Universe => {
|
||||||
writer.write_token("universe");
|
writer.write_token("universe");
|
||||||
}
|
}
|
||||||
@@ -377,12 +368,6 @@ impl TimelineKind {
|
|||||||
TokenParser::alt(
|
TokenParser::alt(
|
||||||
parser,
|
parser,
|
||||||
&[
|
&[
|
||||||
|p| {
|
|
||||||
p.parse_token("thread")?;
|
|
||||||
Ok(TimelineKind::Thread(ThreadSelection::from_root_id(
|
|
||||||
RootNoteIdBuf::new_unsafe(tokenator::parse_hex_id(p)?),
|
|
||||||
)))
|
|
||||||
},
|
|
||||||
|p| {
|
|p| {
|
||||||
p.parse_token("universe")?;
|
p.parse_token("universe")?;
|
||||||
Ok(TimelineKind::Universe)
|
Ok(TimelineKind::Universe)
|
||||||
@@ -425,10 +410,6 @@ impl TimelineKind {
|
|||||||
TimelineKind::Profile(pk)
|
TimelineKind::Profile(pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn thread(selected_note: ThreadSelection) -> Self {
|
|
||||||
TimelineKind::Thread(selected_note)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_notifications(&self) -> bool {
|
pub fn is_notifications(&self) -> bool {
|
||||||
matches!(self, TimelineKind::Notifications(_))
|
matches!(self, TimelineKind::Notifications(_))
|
||||||
}
|
}
|
||||||
@@ -474,17 +455,6 @@ impl TimelineKind {
|
|||||||
todo!("implement generic filter lookups")
|
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()
|
TimelineKind::Profile(pk) => FilterState::ready(vec![Filter::new()
|
||||||
.authors([pk.bytes()])
|
.authors([pk.bytes()])
|
||||||
.kinds([1])
|
.kinds([1])
|
||||||
@@ -510,8 +480,6 @@ impl TimelineKind {
|
|||||||
TimelineTab::full_tabs(),
|
TimelineTab::full_tabs(),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
TimelineKind::Thread(root_id) => Some(Timeline::thread(root_id)),
|
|
||||||
|
|
||||||
TimelineKind::Generic(_filter_id) => {
|
TimelineKind::Generic(_filter_id) => {
|
||||||
warn!("you can't convert a TimelineKind::Generic to a Timeline");
|
warn!("you can't convert a TimelineKind::Generic to a Timeline");
|
||||||
// TODO: you actually can! just need to look up the filter id
|
// 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::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"),
|
||||||
TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self),
|
TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self),
|
||||||
TimelineKind::Thread(_root_id) => ColumnTitle::simple("Thread"),
|
|
||||||
TimelineKind::Universe => ColumnTitle::simple("Universe"),
|
TimelineKind::Universe => ColumnTitle::simple("Universe"),
|
||||||
TimelineKind::Generic(_) => ColumnTitle::simple("Custom"),
|
TimelineKind::Generic(_) => ColumnTitle::simple("Custom"),
|
||||||
TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.to_string()),
|
TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.to_string()),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod kind;
|
pub mod kind;
|
||||||
pub mod route;
|
pub mod route;
|
||||||
|
pub mod thread;
|
||||||
|
|
||||||
pub use cache::TimelineCache;
|
pub use cache::TimelineCache;
|
||||||
pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind};
|
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> {
|
pub fn last_per_pubkey(list: &Note, list_kind: &ListKind) -> Result<Self> {
|
||||||
let kind = 1;
|
let kind = 1;
|
||||||
let notes_per_pk = 1;
|
let notes_per_pk = 1;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
nav::RenderNavAction,
|
nav::RenderNavAction,
|
||||||
profile::ProfileAction,
|
profile::ProfileAction,
|
||||||
timeline::{TimelineCache, TimelineKind},
|
timeline::{thread::Threads, ThreadSelection, TimelineCache, TimelineKind},
|
||||||
ui::{self, ProfileView},
|
ui::{self, ProfileView},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ pub fn render_timeline_route(
|
|||||||
accounts: &mut Accounts,
|
accounts: &mut Accounts,
|
||||||
kind: &TimelineKind,
|
kind: &TimelineKind,
|
||||||
col: usize,
|
col: usize,
|
||||||
mut note_options: NoteOptions,
|
note_options: NoteOptions,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
note_context: &mut NoteContext,
|
note_context: &mut NoteContext,
|
||||||
@@ -74,31 +74,39 @@ pub fn render_timeline_route(
|
|||||||
note_action.map(RenderNavAction::NoteAction)
|
note_action.map(RenderNavAction::NoteAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TimelineKind::Thread(id) => {
|
#[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
|
// don't truncate thread notes for now, since they are
|
||||||
// default truncated everywher eelse
|
// default truncated everywher eelse
|
||||||
note_options.set_truncate(false);
|
note_options.set_truncate(false);
|
||||||
|
|
||||||
// text is selectable in threads
|
|
||||||
note_options.set_selectable_text(true);
|
|
||||||
|
|
||||||
ui::ThreadView::new(
|
ui::ThreadView::new(
|
||||||
timeline_cache,
|
threads,
|
||||||
unknown_ids,
|
unknown_ids,
|
||||||
id.selected_or_root(),
|
selection.selected_or_root(),
|
||||||
note_options,
|
note_options,
|
||||||
&accounts.mutefun(),
|
&accounts.mutefun(),
|
||||||
note_context,
|
note_context,
|
||||||
&accounts.get_selected_account().map(|a| (&a.key).into()),
|
&accounts.get_selected_account().map(|a| (&a.key).into()),
|
||||||
jobs,
|
jobs,
|
||||||
)
|
)
|
||||||
.id_source(egui::Id::new(("threadscroll", col)))
|
.id_source(col)
|
||||||
.ui(ui)
|
.ui(ui)
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn render_profile_route(
|
pub fn render_profile_route(
|
||||||
@@ -139,30 +147,3 @@ pub fn render_profile_route(
|
|||||||
None
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
528
crates/notedeck_columns/src/timeline/thread.rs
Normal file
528
crates/notedeck_columns/src/timeline/thread.rs
Normal 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(¬e_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contains(&self, note_id: &[u8; 32]) -> bool {
|
||||||
|
self.flags.contains_key(¬e_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::column::ColumnsAction;
|
use crate::column::ColumnsAction;
|
||||||
use crate::nav::RenderNavAction;
|
use crate::nav::RenderNavAction;
|
||||||
use crate::nav::SwitchingAction;
|
use crate::nav::SwitchingAction;
|
||||||
|
use crate::timeline::ThreadSelection;
|
||||||
use crate::{
|
use crate::{
|
||||||
column::Columns,
|
column::Columns,
|
||||||
route::Route,
|
route::Route,
|
||||||
@@ -437,11 +438,6 @@ impl<'a> NavTitle<'a> {
|
|||||||
|
|
||||||
TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
|
TimelineKind::Profile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
|
||||||
|
|
||||||
TimelineKind::Thread(_) => {
|
|
||||||
// no pfp for threads
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
TimelineKind::Search(_sq) => {
|
TimelineKind::Search(_sq) => {
|
||||||
// TODO: show author pfp if author field set?
|
// 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::Search => Some(ui.add(ui::side_panel::search_button())),
|
||||||
Route::Wallet(_) => None,
|
Route::Wallet(_) => None,
|
||||||
Route::CustomizeZapAmount(_) => 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 {
|
fn title_label_value(title: &str) -> egui::Label {
|
||||||
egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
|
egui::Label::new(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()))
|
||||||
.selectable(false)
|
.selectable(false)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
|
use egui::InnerResponse;
|
||||||
|
use egui_virtual_list::VirtualList;
|
||||||
use enostr::KeypairUnowned;
|
use enostr::KeypairUnowned;
|
||||||
use nostrdb::Transaction;
|
use nostrdb::{Note, Transaction};
|
||||||
use notedeck::{MuteFun, NoteAction, NoteContext, RootNoteId, UnknownIds};
|
use notedeck::note::root_note_id_from_selected_id;
|
||||||
|
use notedeck::{MuteFun, NoteAction, NoteContext, UnknownIds};
|
||||||
use notedeck_ui::jobs::JobsCache;
|
use notedeck_ui::jobs::JobsCache;
|
||||||
use notedeck_ui::NoteOptions;
|
use notedeck_ui::note::NoteResponse;
|
||||||
use tracing::error;
|
use notedeck_ui::{NoteOptions, NoteView};
|
||||||
|
|
||||||
use crate::timeline::{ThreadSelection, TimelineCache, TimelineKind};
|
use crate::timeline::thread::{NoteSeenFlags, ParentState, Threads};
|
||||||
use crate::ui::timeline::TimelineTabView;
|
|
||||||
|
|
||||||
pub struct ThreadView<'a, 'd> {
|
pub struct ThreadView<'a, 'd> {
|
||||||
timeline_cache: &'a mut TimelineCache,
|
threads: &'a mut Threads,
|
||||||
unknown_ids: &'a mut UnknownIds,
|
unknown_ids: &'a mut UnknownIds,
|
||||||
selected_note_id: &'a [u8; 32],
|
selected_note_id: &'a [u8; 32],
|
||||||
note_options: NoteOptions,
|
note_options: NoteOptions,
|
||||||
|
col: usize,
|
||||||
id_source: egui::Id,
|
id_source: egui::Id,
|
||||||
is_muted: &'a MuteFun,
|
is_muted: &'a MuteFun,
|
||||||
note_context: &'a mut NoteContext<'d>,
|
note_context: &'a mut NoteContext<'d>,
|
||||||
@@ -23,7 +26,7 @@ pub struct ThreadView<'a, 'd> {
|
|||||||
impl<'a, 'd> ThreadView<'a, 'd> {
|
impl<'a, 'd> ThreadView<'a, 'd> {
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
timeline_cache: &'a mut TimelineCache,
|
threads: &'a mut Threads,
|
||||||
unknown_ids: &'a mut UnknownIds,
|
unknown_ids: &'a mut UnknownIds,
|
||||||
selected_note_id: &'a [u8; 32],
|
selected_note_id: &'a [u8; 32],
|
||||||
note_options: NoteOptions,
|
note_options: NoteOptions,
|
||||||
@@ -34,7 +37,7 @@ impl<'a, 'd> ThreadView<'a, 'd> {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
let id_source = egui::Id::new("threadscroll_threadview");
|
let id_source = egui::Id::new("threadscroll_threadview");
|
||||||
ThreadView {
|
ThreadView {
|
||||||
timeline_cache,
|
threads,
|
||||||
unknown_ids,
|
unknown_ids,
|
||||||
selected_note_id,
|
selected_note_id,
|
||||||
note_options,
|
note_options,
|
||||||
@@ -43,11 +46,13 @@ impl<'a, 'd> ThreadView<'a, 'd> {
|
|||||||
note_context,
|
note_context,
|
||||||
cur_acc,
|
cur_acc,
|
||||||
jobs,
|
jobs,
|
||||||
|
col: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn id_source(mut self, id: egui::Id) -> Self {
|
pub fn id_source(mut self, col: usize) -> Self {
|
||||||
self.id_source = id;
|
self.col = col;
|
||||||
|
self.id_source = egui::Id::new(("threadscroll", col));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,66 +65,355 @@ impl<'a, 'd> ThreadView<'a, 'd> {
|
|||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysVisible);
|
.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)) {
|
if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
|
||||||
scroll_area = scroll_area.vertical_scroll_offset(offset);
|
scroll_area = scroll_area.vertical_scroll_offset(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = scroll_area.show(ui, |ui| {
|
let output = scroll_area.show(ui, |ui| self.notes(ui, &txn));
|
||||||
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)
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
|
ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
|
||||||
|
|
||||||
output.inner
|
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,
|
||||||
|
¬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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ¬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
|
||||||
|
};
|
||||||
|
|||||||
@@ -46,12 +46,16 @@ pub fn padding<R>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn hline(ui: &egui::Ui) {
|
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
|
// pixel perfect horizontal line
|
||||||
let rect = ui.available_rect_before_wrap();
|
let rect = ui.available_rect_before_wrap();
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5;
|
let resize_y = ui.painter().round_to_pixel(rect.top()) - 0.5;
|
||||||
let stroke = ui.style().visuals.widgets.noninteractive.bg_stroke;
|
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) {
|
pub fn show_pointer(ui: &egui::Ui) {
|
||||||
|
|||||||
@@ -270,11 +270,17 @@ pub fn render_note_contents(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let preview_note_action = if let Some((id, _block_str)) = inline_note {
|
let preview_note_action = inline_note.and_then(|(id, _)| {
|
||||||
render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options, jobs).action
|
render_note_preview(ui, note_context, cur_acc, txn, id, note_key, options, jobs)
|
||||||
} else {
|
.action
|
||||||
None
|
.map(|a| match a {
|
||||||
};
|
NoteAction::Note { note_id, .. } => NoteAction::Note {
|
||||||
|
note_id,
|
||||||
|
preview: true,
|
||||||
|
},
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
let mut media_action = None;
|
let mut media_action = None;
|
||||||
if !supported_medias.is_empty() && !options.has_textmode() {
|
if !supported_medias.is_empty() && !options.has_textmode() {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub use contents::{render_note_contents, render_note_preview, NoteContents};
|
|||||||
pub use context::NoteContextButton;
|
pub use context::NoteContextButton;
|
||||||
use notedeck::note::MediaAction;
|
use notedeck::note::MediaAction;
|
||||||
use notedeck::note::ZapTargetAmount;
|
use notedeck::note::ZapTargetAmount;
|
||||||
|
use notedeck::Images;
|
||||||
pub use options::NoteOptions;
|
pub use options::NoteOptions;
|
||||||
pub use reply_description::reply_desc;
|
pub use reply_description::reply_desc;
|
||||||
|
|
||||||
@@ -36,11 +37,13 @@ pub struct NoteView<'a, 'd> {
|
|||||||
framed: bool,
|
framed: bool,
|
||||||
flags: NoteOptions,
|
flags: NoteOptions,
|
||||||
jobs: &'a mut JobsCache,
|
jobs: &'a mut JobsCache,
|
||||||
|
show_unread_indicator: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NoteResponse {
|
pub struct NoteResponse {
|
||||||
pub response: egui::Response,
|
pub response: egui::Response,
|
||||||
pub action: Option<NoteAction>,
|
pub action: Option<NoteAction>,
|
||||||
|
pub pfp_rect: Option<egui::Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoteResponse {
|
impl NoteResponse {
|
||||||
@@ -48,6 +51,7 @@ impl NoteResponse {
|
|||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
action: None,
|
action: None,
|
||||||
|
pfp_rect: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +59,11 @@ impl NoteResponse {
|
|||||||
self.action = action;
|
self.action = action;
|
||||||
self
|
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,
|
flags,
|
||||||
framed,
|
framed,
|
||||||
jobs,
|
jobs,
|
||||||
|
show_unread_indicator: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +189,11 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self
|
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 {
|
fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
|
||||||
let note_key = self.note.key().expect("todo: implement non-db notes");
|
let note_key = self.note.key().expect("todo: implement non-db notes");
|
||||||
let txn = self.note.txn().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,
|
note_key: NoteKey,
|
||||||
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
|
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
) -> (egui::Response, Option<MediaAction>) {
|
) -> PfpResponse {
|
||||||
let mut action = None;
|
|
||||||
if !self.options().has_wide() {
|
if !self.options().has_wide() {
|
||||||
ui.spacing_mut().item_spacing.x = 16.0;
|
ui.spacing_mut().item_spacing.x = 16.0;
|
||||||
} else {
|
} else {
|
||||||
@@ -244,68 +258,32 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
|
|
||||||
let pfp_size = self.options().pfp_size();
|
let pfp_size = self.options().pfp_size();
|
||||||
|
|
||||||
let sense = Sense::click();
|
match profile
|
||||||
let resp = match profile
|
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.record().profile()?.picture())
|
.and_then(|p| p.record().profile()?.picture())
|
||||||
{
|
{
|
||||||
// these have different lifetimes and types,
|
// these have different lifetimes and types,
|
||||||
// so the calls must be separate
|
// so the calls must be separate
|
||||||
Some(pic) => {
|
Some(pic) => show_actual_pfp(
|
||||||
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,
|
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);
|
|
||||||
|
|
||||||
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(),
|
|
||||||
self.note_context.img_cache,
|
self.note_context.img_cache,
|
||||||
));
|
pic,
|
||||||
});
|
pfp_size,
|
||||||
|
note_key,
|
||||||
|
profile,
|
||||||
|
),
|
||||||
|
|
||||||
resp
|
None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
None => {
|
fn show_repost(
|
||||||
// This has to match the expand size from the above case to
|
&mut self,
|
||||||
// prevent bounciness
|
ui: &mut egui::Ui,
|
||||||
let size = (pfp_size + NoteView::expand_size()) as f32;
|
txn: &Transaction,
|
||||||
let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
|
note_to_repost: Note<'_>,
|
||||||
|
) -> NoteResponse {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
let profile = self
|
||||||
.note_context
|
.note_context
|
||||||
.ndb
|
.ndb
|
||||||
@@ -345,6 +323,12 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self.jobs,
|
self.jobs,
|
||||||
)
|
)
|
||||||
.show(ui)
|
.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) {
|
||||||
|
self.show_repost(ui, txn, note_to_repost)
|
||||||
} else {
|
} else {
|
||||||
self.show_standard(ui)
|
self.show_standard(ui)
|
||||||
}
|
}
|
||||||
@@ -376,16 +360,33 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
note_cache: &mut NoteCache,
|
note_cache: &mut NoteCache,
|
||||||
note: &Note,
|
note: &Note,
|
||||||
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
|
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
|
||||||
|
show_unread_indicator: bool,
|
||||||
) {
|
) {
|
||||||
let note_key = note.key().unwrap();
|
let note_key = note.key().unwrap();
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
let horiz_resp = ui
|
||||||
|
.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 2.0;
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
|
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);
|
let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
|
||||||
render_reltime(ui, cached_note, true);
|
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(
|
fn wide_ui(
|
||||||
@@ -394,20 +395,19 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
txn: &Transaction,
|
txn: &Transaction,
|
||||||
note_key: NoteKey,
|
note_key: NoteKey,
|
||||||
profile: &Result<ProfileRecord, nostrdb::Error>,
|
profile: &Result<ProfileRecord, nostrdb::Error>,
|
||||||
) -> egui::InnerResponse<Option<NoteAction>> {
|
) -> egui::InnerResponse<NoteUiResponse> {
|
||||||
let mut note_action: Option<NoteAction> = None;
|
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
||||||
ui.horizontal(|ui| {
|
let mut note_action: Option<NoteAction> = None;
|
||||||
let (pfp_resp, action) = self.pfp(note_key, profile, ui);
|
let pfp_rect = ui
|
||||||
if pfp_resp.clicked() {
|
.horizontal(|ui| {
|
||||||
note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey())));
|
let pfp_resp = self.pfp(note_key, profile, ui);
|
||||||
} else if let Some(action) = action {
|
let pfp_rect = pfp_resp.bounding_rect;
|
||||||
note_action = Some(NoteAction::Media(action));
|
note_action = pfp_resp
|
||||||
};
|
.into_action(self.note.pubkey())
|
||||||
|
.or(note_action.take());
|
||||||
|
|
||||||
let size = ui.available_size();
|
let size = ui.available_size();
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| 's: {
|
||||||
ui.add_sized(
|
ui.add_sized(
|
||||||
[size.x, self.options().pfp_size() as f32],
|
[size.x, self.options().pfp_size() as f32],
|
||||||
|ui: &mut egui::Ui| {
|
|ui: &mut egui::Ui| {
|
||||||
@@ -417,6 +417,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self.note_context.note_cache,
|
self.note_context.note_cache,
|
||||||
self.note,
|
self.note,
|
||||||
profile,
|
profile,
|
||||||
|
self.show_unread_indicator,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.response
|
.response
|
||||||
@@ -430,10 +431,12 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
.reply
|
.reply
|
||||||
.borrow(self.note.tags());
|
.borrow(self.note.tags());
|
||||||
|
|
||||||
if note_reply.reply().is_some() {
|
if note_reply.reply().is_none() {
|
||||||
let action = ui
|
break 's;
|
||||||
.horizontal(|ui| {
|
}
|
||||||
reply_desc(
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
note_action = reply_desc(
|
||||||
ui,
|
ui,
|
||||||
self.zapping_acc,
|
self.zapping_acc,
|
||||||
txn,
|
txn,
|
||||||
@@ -442,16 +445,14 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self.flags,
|
self.flags,
|
||||||
self.jobs,
|
self.jobs,
|
||||||
)
|
)
|
||||||
|
.or(note_action.take());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pfp_rect
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
|
|
||||||
if action.is_some() {
|
|
||||||
note_action = action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut contents = NoteContents::new(
|
let mut contents = NoteContents::new(
|
||||||
self.note_context,
|
self.note_context,
|
||||||
self.zapping_acc,
|
self.zapping_acc,
|
||||||
@@ -463,12 +464,10 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
|
|
||||||
ui.add(&mut contents);
|
ui.add(&mut contents);
|
||||||
|
|
||||||
if let Some(action) = contents.action {
|
note_action = contents.action.or(note_action);
|
||||||
note_action = Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.options().has_actionbar() {
|
if self.options().has_actionbar() {
|
||||||
if let Some(action) = render_note_actionbar(
|
note_action = render_note_actionbar(
|
||||||
ui,
|
ui,
|
||||||
self.zapping_acc.as_ref().map(|c| Zapper {
|
self.zapping_acc.as_ref().map(|c| Zapper {
|
||||||
zaps: self.note_context.zaps,
|
zaps: self.note_context.zaps,
|
||||||
@@ -479,12 +478,13 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
note_key,
|
note_key,
|
||||||
)
|
)
|
||||||
.inner
|
.inner
|
||||||
{
|
.or(note_action);
|
||||||
note_action = Some(action);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
note_action
|
NoteUiResponse {
|
||||||
|
action: note_action,
|
||||||
|
pfp_rect,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,20 +494,22 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
txn: &Transaction,
|
txn: &Transaction,
|
||||||
note_key: NoteKey,
|
note_key: NoteKey,
|
||||||
profile: &Result<ProfileRecord, nostrdb::Error>,
|
profile: &Result<ProfileRecord, nostrdb::Error>,
|
||||||
) -> egui::InnerResponse<Option<NoteAction>> {
|
) -> egui::InnerResponse<NoteUiResponse> {
|
||||||
let mut note_action: Option<NoteAction> = None;
|
|
||||||
// main design
|
// main design
|
||||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
|
||||||
let (pfp_resp, action) = self.pfp(note_key, profile, ui);
|
let pfp_resp = self.pfp(note_key, profile, ui);
|
||||||
if pfp_resp.clicked() {
|
let pfp_rect = pfp_resp.bounding_rect;
|
||||||
note_action = Some(NoteAction::Profile(Pubkey::new(*self.note.pubkey())));
|
let mut note_action: Option<NoteAction> = pfp_resp.into_action(self.note.pubkey());
|
||||||
} else if let Some(action) = action {
|
|
||||||
note_action = Some(NoteAction::Media(action));
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
|
||||||
NoteView::note_header(ui, self.note_context.note_cache, self.note, profile);
|
NoteView::note_header(
|
||||||
ui.horizontal(|ui| {
|
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;
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
|
|
||||||
let note_reply = self
|
let note_reply = self
|
||||||
@@ -517,8 +519,11 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
.reply
|
.reply
|
||||||
.borrow(self.note.tags());
|
.borrow(self.note.tags());
|
||||||
|
|
||||||
if note_reply.reply().is_some() {
|
if note_reply.reply().is_none() {
|
||||||
let action = reply_desc(
|
break 's;
|
||||||
|
}
|
||||||
|
|
||||||
|
note_action = reply_desc(
|
||||||
ui,
|
ui,
|
||||||
self.zapping_acc,
|
self.zapping_acc,
|
||||||
txn,
|
txn,
|
||||||
@@ -526,12 +531,8 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self.note_context,
|
self.note_context,
|
||||||
self.flags,
|
self.flags,
|
||||||
self.jobs,
|
self.jobs,
|
||||||
);
|
)
|
||||||
|
.or(note_action.take());
|
||||||
if action.is_some() {
|
|
||||||
note_action = action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut contents = NoteContents::new(
|
let mut contents = NoteContents::new(
|
||||||
@@ -544,12 +545,10 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
);
|
);
|
||||||
ui.add(&mut contents);
|
ui.add(&mut contents);
|
||||||
|
|
||||||
if let Some(action) = contents.action {
|
note_action = contents.action.or(note_action);
|
||||||
note_action = Some(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.options().has_actionbar() {
|
if self.options().has_actionbar() {
|
||||||
if let Some(action) = render_note_actionbar(
|
note_action = render_note_actionbar(
|
||||||
ui,
|
ui,
|
||||||
self.zapping_acc.as_ref().map(|c| Zapper {
|
self.zapping_acc.as_ref().map(|c| Zapper {
|
||||||
zaps: self.note_context.zaps,
|
zaps: self.note_context.zaps,
|
||||||
@@ -560,12 +559,13 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
note_key,
|
note_key,
|
||||||
)
|
)
|
||||||
.inner
|
.inner
|
||||||
{
|
.or(note_action);
|
||||||
note_action = Some(action);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
note_action
|
NoteUiResponse {
|
||||||
|
action: note_action,
|
||||||
|
pfp_rect,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
})
|
})
|
||||||
@@ -591,7 +591,8 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
self.standard_ui(ui, txn, note_key, &profile)
|
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() {
|
if self.options().has_options_button() {
|
||||||
let context_pos = {
|
let context_pos = {
|
||||||
@@ -607,19 +608,22 @@ impl<'a, 'd> NoteView<'a, 'd> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let note_action =
|
note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox)
|
||||||
if note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) {
|
.then_some(NoteAction::note(NoteId::new(*self.note.id())))
|
||||||
Some(NoteAction::Note(NoteId::new(*self.note.id())))
|
.or(note_action);
|
||||||
} else {
|
|
||||||
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>> {
|
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;
|
let mut res = None;
|
||||||
for tag in note.tags().iter() {
|
for tag in note.tags().iter() {
|
||||||
if tag.count() == 0 {
|
if tag.count() == 0 {
|
||||||
@@ -634,14 +638,90 @@ fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
res?
|
res?
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let note = ndb.get_note_by_id(txn, new_note_id).ok();
|
let note = ndb.get_note_by_id(txn, new_note_id).ok();
|
||||||
note.filter(|note| note.kind() == 1)
|
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(
|
fn note_hitbox_id(
|
||||||
note_key: NoteKey,
|
note_key: NoteKey,
|
||||||
note_options: NoteOptions,
|
note_options: NoteOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user