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