Merge initial threads
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2296,7 +2296,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostrdb"
|
name = "nostrdb"
|
||||||
version = "0.3.4"
|
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 = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"cc",
|
"cc",
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ serde_json = "1.0.89"
|
|||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
puffin_egui = { version = "0.27.0", optional = true }
|
puffin_egui = { version = "0.27.0", optional = true }
|
||||||
puffin = { version = "0.19.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"
|
#nostrdb = "0.3.4"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
base32 = "0.4.0"
|
base32 = "0.4.0"
|
||||||
|
|||||||
133
src/actionbar.rs
133
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 enostr::NoteId;
|
||||||
|
use nostrdb::Transaction;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||||
pub enum BarAction {
|
pub enum BarAction {
|
||||||
@@ -7,8 +14,101 @@ pub enum BarAction {
|
|||||||
OpenThread,
|
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 {
|
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 {
|
match self {
|
||||||
BarAction::Reply => {
|
BarAction::Reply => {
|
||||||
let timeline = &mut app.timelines[timeline];
|
let timeline = &mut app.timelines[timeline];
|
||||||
@@ -16,15 +116,30 @@ impl BarAction {
|
|||||||
.routes
|
.routes
|
||||||
.push(Route::Reply(NoteId::new(replying_to.to_owned())));
|
.push(Route::Reply(NoteId::new(replying_to.to_owned())));
|
||||||
timeline.navigating = true;
|
timeline.navigating = true;
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
BarAction::OpenThread => {
|
BarAction::OpenThread => open_thread(app, txn, timeline, replying_to),
|
||||||
let timeline = &mut app.timelines[timeline];
|
|
||||||
timeline
|
|
||||||
.routes
|
|
||||||
.push(Route::Thread(NoteId::new(replying_to.to_owned())));
|
|
||||||
timeline.navigating = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
229
src/app.rs
229
src/app.rs
@@ -1,8 +1,8 @@
|
|||||||
use crate::account_manager::AccountManager;
|
use crate::account_manager::AccountManager;
|
||||||
|
use crate::actionbar::BarResult;
|
||||||
use crate::app_creation::setup_cc;
|
use crate::app_creation::setup_cc;
|
||||||
use crate::app_style::user_requested_visuals_change;
|
use crate::app_style::user_requested_visuals_change;
|
||||||
use crate::draft::Drafts;
|
use crate::draft::Drafts;
|
||||||
use crate::error::Error;
|
|
||||||
use crate::frame_history::FrameHistory;
|
use crate::frame_history::FrameHistory;
|
||||||
use crate::imgcache::ImageCache;
|
use crate::imgcache::ImageCache;
|
||||||
use crate::key_storage::KeyStorageType;
|
use crate::key_storage::KeyStorageType;
|
||||||
@@ -10,8 +10,8 @@ use crate::note::NoteRef;
|
|||||||
use crate::notecache::{CachedNote, NoteCache};
|
use crate::notecache::{CachedNote, NoteCache};
|
||||||
use crate::relay_pool_manager::RelayPoolManager;
|
use crate::relay_pool_manager::RelayPoolManager;
|
||||||
use crate::route::Route;
|
use crate::route::Route;
|
||||||
use crate::timeline;
|
use crate::thread::{DecrementResult, Threads};
|
||||||
use crate::timeline::{MergeKind, Timeline, ViewFilter};
|
use crate::timeline::{Timeline, TimelineSource, ViewFilter};
|
||||||
use crate::ui::note::PostAction;
|
use crate::ui::note::PostAction;
|
||||||
use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup};
|
use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup};
|
||||||
use crate::ui::{DesktopSidePanel, RelayView, View};
|
use crate::ui::{DesktopSidePanel, RelayView, View};
|
||||||
@@ -53,10 +53,11 @@ pub struct Damus {
|
|||||||
|
|
||||||
pub timelines: Vec<Timeline>,
|
pub timelines: Vec<Timeline>,
|
||||||
pub selected_timeline: i32,
|
pub selected_timeline: i32,
|
||||||
pub drafts: Drafts,
|
|
||||||
|
|
||||||
pub img_cache: ImageCache,
|
|
||||||
pub ndb: Ndb,
|
pub ndb: Ndb,
|
||||||
|
pub drafts: Drafts,
|
||||||
|
pub threads: Threads,
|
||||||
|
pub img_cache: ImageCache,
|
||||||
pub account_manager: AccountManager,
|
pub account_manager: AccountManager,
|
||||||
|
|
||||||
frame_history: crate::frame_history::FrameHistory,
|
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
|
/// 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,
|
/// and seeing what its limit is. If we have less notes than the limit,
|
||||||
/// we might want to backfill older notes
|
/// 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) {
|
fn send_initial_filters(damus: &mut Damus, relay_url: &str) {
|
||||||
info!("Sending initial filters to {}", relay_url);
|
info!("Sending initial filters to {}", relay_url);
|
||||||
let mut c: u32 = 1;
|
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);
|
let notes = timeline.notes(ViewFilter::NotesAndReplies);
|
||||||
if should_since_optimize(f.limit, notes.len()) {
|
if crate::filter::should_since_optimize(f.limit, notes.len()) {
|
||||||
since_optimize_filter(f, notes);
|
crate::filter::since_optimize_filter(f, notes);
|
||||||
} else {
|
} else {
|
||||||
warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", f);
|
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 txn = Transaction::new(&damus.ndb)?;
|
||||||
let mut unknown_ids: HashSet<UnknownId> = HashSet::new();
|
let mut unknown_ids: HashSet<UnknownId> = HashSet::new();
|
||||||
for timeline in 0..damus.timelines.len() {
|
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);
|
error!("{}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +229,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Hash, Clone, Copy, PartialEq, Eq)]
|
#[derive(Hash, Clone, Copy, PartialEq, Eq)]
|
||||||
enum UnknownId<'a> {
|
pub enum UnknownId<'a> {
|
||||||
Pubkey(&'a [u8; 32]),
|
Pubkey(&'a [u8; 32]),
|
||||||
Id(&'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
|
/// We return all of this in a HashSet so that we can fetch these from
|
||||||
/// remote relays.
|
/// remote relays.
|
||||||
///
|
///
|
||||||
fn get_unknown_note_ids<'a>(
|
pub fn get_unknown_note_ids<'a>(
|
||||||
ndb: &Ndb,
|
ndb: &Ndb,
|
||||||
_cached_note: &CachedNote,
|
cached_note: &CachedNote,
|
||||||
txn: &'a Transaction,
|
txn: &'a Transaction,
|
||||||
note: &Note<'a>,
|
note: &Note<'a>,
|
||||||
note_key: NoteKey,
|
note_key: NoteKey,
|
||||||
@@ -292,7 +273,6 @@ fn get_unknown_note_ids<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pull notes that notes are replying to
|
// pull notes that notes are replying to
|
||||||
/* TODO: FIX tags lifetime
|
|
||||||
if cached_note.reply.root.is_some() {
|
if cached_note.reply.root.is_some() {
|
||||||
let note_reply = cached_note.reply.borrow(note.tags());
|
let note_reply = cached_note.reply.borrow(note.tags());
|
||||||
if let Some(root) = note_reply.root() {
|
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)?;
|
let blocks = ndb.get_blocks_by_key(txn, note_key)?;
|
||||||
for block in blocks.iter(note) {
|
for block in blocks.iter(note) {
|
||||||
@@ -360,101 +339,6 @@ fn get_unknown_note_ids<'a>(
|
|||||||
Ok(())
|
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")]
|
#[cfg(feature = "profiling")]
|
||||||
fn setup_profiling() {
|
fn setup_profiling() {
|
||||||
puffin::set_scopes_on(true); // tell puffin to collect data
|
puffin::set_scopes_on(true); // tell puffin to collect data
|
||||||
@@ -762,6 +646,7 @@ fn parse_args(args: &[String]) -> Args {
|
|||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
fn determine_key_storage_type() -> KeyStorageType {
|
fn determine_key_storage_type() -> KeyStorageType {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
@@ -778,6 +663,7 @@ fn determine_key_storage_type() -> KeyStorageType {
|
|||||||
KeyStorageType::None
|
KeyStorageType::None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
impl Damus {
|
impl Damus {
|
||||||
/// Called once before the first frame.
|
/// Called once before the first frame.
|
||||||
@@ -808,7 +694,7 @@ impl Damus {
|
|||||||
// TODO: should pull this from settings
|
// TODO: should pull this from settings
|
||||||
None,
|
None,
|
||||||
// TODO: use correct KeyStorage mechanism for current OS arch
|
// TODO: use correct KeyStorage mechanism for current OS arch
|
||||||
determine_key_storage_type(),
|
KeyStorageType::None,
|
||||||
);
|
);
|
||||||
|
|
||||||
for key in parsed_args.keys {
|
for key in parsed_args.keys {
|
||||||
@@ -843,6 +729,7 @@ impl Damus {
|
|||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
is_mobile,
|
is_mobile,
|
||||||
|
threads: Threads::default(),
|
||||||
drafts: Drafts::default(),
|
drafts: Drafts::default(),
|
||||||
state: DamusState::Initializing,
|
state: DamusState::Initializing,
|
||||||
img_cache: ImageCache::new(imgcache_dir),
|
img_cache: ImageCache::new(imgcache_dir),
|
||||||
@@ -872,6 +759,7 @@ impl Damus {
|
|||||||
config.set_ingester_threads(2);
|
config.set_ingester_threads(2);
|
||||||
Self {
|
Self {
|
||||||
is_mobile,
|
is_mobile,
|
||||||
|
threads: Threads::default(),
|
||||||
drafts: Drafts::default(),
|
drafts: Drafts::default(),
|
||||||
state: DamusState::Initializing,
|
state: DamusState::Initializing,
|
||||||
pool: RelayPool::new(),
|
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) {
|
fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut egui::Ui) {
|
||||||
let navigating = app.timelines[timeline_ind].navigating;
|
let navigating = app.timelines[timeline_ind].navigating;
|
||||||
let returning = app.timelines[timeline_ind].returning;
|
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() {
|
.show(ui, |ui, nav| match nav.top() {
|
||||||
Route::Timeline(_n) => {
|
Route::Timeline(_n) => {
|
||||||
let app = &mut app_ctx.borrow_mut();
|
let app = &mut app_ctx.borrow_mut();
|
||||||
timeline::timeline_view(ui, app, timeline_ind);
|
ui::TimelineView::new(app, timeline_ind).ui(ui);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,11 +971,6 @@ fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
Route::Thread(_key) => {
|
|
||||||
ui.label("thread view");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
Route::Relays => {
|
Route::Relays => {
|
||||||
let pool = &mut app_ctx.borrow_mut().pool;
|
let pool = &mut app_ctx.borrow_mut().pool;
|
||||||
let manager = RelayPoolManager::new(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
|
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) => {
|
Route::Reply(id) => {
|
||||||
let mut app = app_ctx.borrow_mut();
|
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(reply_response) = nav_response.inner {
|
||||||
if let Some(PostAction::Post(_np)) = reply_response.inner.action {
|
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 {
|
if let Some(NavAction::Returned) = nav_response.action {
|
||||||
let mut app = app_ctx.borrow_mut();
|
let popped = app.timelines[timeline_ind].routes.pop();
|
||||||
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;
|
app.timelines[timeline_ind].returning = false;
|
||||||
} else if let Some(NavAction::Navigated) = nav_response.action {
|
} else if let Some(NavAction::Navigated) = nav_response.action {
|
||||||
app_ctx.borrow_mut().timelines[timeline_ind].navigating = false;
|
app.timelines[timeline_ind].navigating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
src/error.rs
39
src/error.rs
@@ -1,8 +1,41 @@
|
|||||||
use std::{fmt, io};
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
NoActiveSubscription,
|
SubscriptionError(SubscriptionError),
|
||||||
LoadFailed,
|
LoadFailed,
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
Nostr(enostr::Error),
|
Nostr(enostr::Error),
|
||||||
@@ -14,8 +47,8 @@ pub enum Error {
|
|||||||
impl fmt::Display for Error {
|
impl fmt::Display for Error {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::NoActiveSubscription => {
|
Self::SubscriptionError(sub_err) => {
|
||||||
write!(f, "subscription not active in timeline")
|
write!(f, "{sub_err}")
|
||||||
}
|
}
|
||||||
Self::LoadFailed => {
|
Self::LoadFailed => {
|
||||||
write!(f, "load failed")
|
write!(f, "load failed")
|
||||||
|
|||||||
@@ -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 {
|
pub fn convert_enostr_filter(filter: &enostr::Filter) -> nostrdb::Filter {
|
||||||
let mut nfilter = nostrdb::Filter::new();
|
let mut nfilter = nostrdb::Filter::new();
|
||||||
|
|
||||||
if let Some(ref ids) = filter.ids {
|
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 {
|
if let Some(ref authors) = filter.authors {
|
||||||
let authors: Vec<[u8; 32]> = authors.iter().map(|a| *a.bytes()).collect();
|
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 {
|
if let Some(ref kinds) = filter.kinds {
|
||||||
nfilter.kinds(kinds.clone());
|
nfilter = nfilter.kinds(kinds.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// #e
|
// #e
|
||||||
if let Some(ref events) = filter.events {
|
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
|
// #p
|
||||||
if let Some(ref pubkeys) = filter.pubkeys {
|
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
|
// #t
|
||||||
if let Some(ref hashtags) = filter.hashtags {
|
if let Some(ref hashtags) = filter.hashtags {
|
||||||
nfilter.tags(hashtags.clone(), 't');
|
nfilter = nfilter.tags(hashtags.clone(), 't');
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(since) = filter.since {
|
if let Some(since) = filter.since {
|
||||||
nfilter.since(since);
|
nfilter = nfilter.since(since);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(until) = filter.until {
|
if let Some(until) = filter.until {
|
||||||
nfilter.until(until);
|
nfilter = nfilter.until(until);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = filter.limit {
|
if let Some(limit) = filter.limit {
|
||||||
nfilter.limit(limit.into());
|
nfilter = nfilter.limit(limit.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
nfilter.build()
|
nfilter.build()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub mod relay_pool_manager;
|
|||||||
mod result;
|
mod result;
|
||||||
mod route;
|
mod route;
|
||||||
mod test_data;
|
mod test_data;
|
||||||
|
mod thread;
|
||||||
mod time;
|
mod time;
|
||||||
mod timecache;
|
mod timecache;
|
||||||
mod timeline;
|
mod timeline;
|
||||||
|
|||||||
32
src/note.rs
32
src/note.rs
@@ -1,4 +1,5 @@
|
|||||||
use nostrdb::{NoteKey, QueryResult};
|
use crate::Damus;
|
||||||
|
use nostrdb::{NoteKey, QueryResult, Transaction};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
|
||||||
@@ -35,3 +36,32 @@ impl PartialOrd for NoteRef {
|
|||||||
Some(self.cmp(other))
|
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)
|
||||||
|
}
|
||||||
|
|||||||
189
src/thread.rs
Normal file
189
src/thread.rs
Normal file
@@ -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 {
|
||||||
|
//}
|
||||||
|
}
|
||||||
385
src/timeline.rs
385
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::note::NoteRef;
|
||||||
use crate::notecache::CachedNote;
|
use crate::notecache::CachedNote;
|
||||||
use crate::ui::note::PostAction;
|
use crate::{Damus, Result};
|
||||||
use crate::{ui, Damus};
|
|
||||||
|
|
||||||
use crate::route::Route;
|
use crate::route::Route;
|
||||||
use egui::containers::scroll_area::ScrollBarVisibility;
|
|
||||||
use egui::{Direction, Layout};
|
|
||||||
|
|
||||||
use egui_tabs::TabColor;
|
|
||||||
use egui_virtual_list::VirtualList;
|
use egui_virtual_list::VirtualList;
|
||||||
use enostr::Filter;
|
use enostr::Filter;
|
||||||
use nostrdb::{Note, Subscription, Transaction};
|
use nostrdb::{Note, Subscription, Transaction};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::rc::Rc;
|
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)]
|
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
|
||||||
pub enum ViewFilter {
|
pub enum ViewFilter {
|
||||||
@@ -58,19 +189,19 @@ impl ViewFilter {
|
|||||||
|
|
||||||
/// A timeline view is a filtered view of notes in a timeline. Two standard views
|
/// 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,
|
/// 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.
|
/// be captured by a Filter itself.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct TimelineView {
|
pub struct TimelineTab {
|
||||||
pub notes: Vec<NoteRef>,
|
pub notes: Vec<NoteRef>,
|
||||||
pub selection: i32,
|
pub selection: i32,
|
||||||
pub filter: ViewFilter,
|
pub filter: ViewFilter,
|
||||||
pub list: Rc<RefCell<VirtualList>>,
|
pub list: Rc<RefCell<VirtualList>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TimelineView {
|
impl TimelineTab {
|
||||||
pub fn new(filter: ViewFilter) -> Self {
|
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 {
|
pub fn new_with_capacity(filter: ViewFilter, cap: usize) -> Self {
|
||||||
@@ -80,7 +211,7 @@ impl TimelineView {
|
|||||||
let list = Rc::new(RefCell::new(list));
|
let list = Rc::new(RefCell::new(list));
|
||||||
let notes: Vec<NoteRef> = Vec::with_capacity(cap);
|
let notes: Vec<NoteRef> = Vec::with_capacity(cap);
|
||||||
|
|
||||||
TimelineView {
|
TimelineTab {
|
||||||
notes,
|
notes,
|
||||||
selection,
|
selection,
|
||||||
filter,
|
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) {
|
pub fn select_down(&mut self) {
|
||||||
debug!("select_down {}", self.selection + 1);
|
debug!("select_down {}", self.selection + 1);
|
||||||
if self.selection + 1 > self.notes.len() as i32 {
|
if self.selection + 1 > self.notes.len() as i32 {
|
||||||
@@ -109,7 +269,7 @@ impl TimelineView {
|
|||||||
|
|
||||||
pub struct Timeline {
|
pub struct Timeline {
|
||||||
pub filter: Vec<Filter>,
|
pub filter: Vec<Filter>,
|
||||||
pub views: Vec<TimelineView>,
|
pub views: Vec<TimelineTab>,
|
||||||
pub selected_view: i32,
|
pub selected_view: i32,
|
||||||
pub routes: Vec<Route>,
|
pub routes: Vec<Route>,
|
||||||
pub navigating: bool,
|
pub navigating: bool,
|
||||||
@@ -122,8 +282,8 @@ pub struct Timeline {
|
|||||||
impl Timeline {
|
impl Timeline {
|
||||||
pub fn new(filter: Vec<Filter>) -> Self {
|
pub fn new(filter: Vec<Filter>) -> Self {
|
||||||
let subscription: Option<Subscription> = None;
|
let subscription: Option<Subscription> = None;
|
||||||
let notes = TimelineView::new(ViewFilter::Notes);
|
let notes = TimelineTab::new(ViewFilter::Notes);
|
||||||
let replies = TimelineView::new(ViewFilter::NotesAndReplies);
|
let replies = TimelineTab::new(ViewFilter::NotesAndReplies);
|
||||||
let views = vec![notes, replies];
|
let views = vec![notes, replies];
|
||||||
let selected_view = 0;
|
let selected_view = 0;
|
||||||
let routes = vec![Route::Timeline("Timeline".to_string())];
|
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]
|
&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]
|
&mut self.views[self.selected_view as usize]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,202 +313,15 @@ impl Timeline {
|
|||||||
&self.views[view.index()].notes
|
&self.views[view.index()].notes
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self, view: ViewFilter) -> &TimelineView {
|
pub fn view(&self, view: ViewFilter) -> &TimelineTab {
|
||||||
&self.views[view.index()]
|
&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()]
|
&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)]
|
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum MergeKind {
|
pub enum MergeKind {
|
||||||
FrontInsert,
|
FrontInsert,
|
||||||
|
|||||||
@@ -5,13 +5,26 @@ pub struct Mention<'a> {
|
|||||||
app: &'a mut Damus,
|
app: &'a mut Damus,
|
||||||
txn: &'a Transaction,
|
txn: &'a Transaction,
|
||||||
pk: &'a [u8; 32],
|
pk: &'a [u8; 32],
|
||||||
|
selectable: bool,
|
||||||
size: f32,
|
size: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Mention<'a> {
|
impl<'a> Mention<'a> {
|
||||||
pub fn new(app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32]) -> Self {
|
pub fn new(app: &'a mut Damus, txn: &'a Transaction, pk: &'a [u8; 32]) -> Self {
|
||||||
let size = 16.0;
|
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 {
|
pub fn size(mut self, size: f32) -> Self {
|
||||||
@@ -22,7 +35,7 @@ impl<'a> Mention<'a> {
|
|||||||
|
|
||||||
impl<'a> egui::Widget for Mention<'a> {
|
impl<'a> egui::Widget for Mention<'a> {
|
||||||
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
|
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],
|
pk: &[u8; 32],
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
size: f32,
|
size: f32,
|
||||||
|
selectable: bool
|
||||||
) -> egui::Response {
|
) -> egui::Response {
|
||||||
#[cfg(feature = "profiling")]
|
#[cfg(feature = "profiling")]
|
||||||
puffin::profile_function!();
|
puffin::profile_function!();
|
||||||
@@ -46,9 +60,10 @@ fn mention_ui(
|
|||||||
"??".to_string()
|
"??".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp = ui.add(egui::Label::new(
|
let resp = ui.add(
|
||||||
egui::RichText::new(name).color(colors::PURPLE).size(size),
|
egui::Label::new(egui::RichText::new(name).color(colors::PURPLE).size(size))
|
||||||
));
|
.selectable(selectable),
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(rec) = profile.as_ref() {
|
if let Some(rec) = profile.as_ref() {
|
||||||
resp.on_hover_ui_at_pointer(|ui| {
|
resp.on_hover_ui_at_pointer(|ui| {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub mod preview;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod relay;
|
pub mod relay;
|
||||||
pub mod side_panel;
|
pub mod side_panel;
|
||||||
|
pub mod thread;
|
||||||
|
pub mod timeline;
|
||||||
pub mod username;
|
pub mod username;
|
||||||
|
|
||||||
pub use account_management::AccountManagementView;
|
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 profile::{profile_preview_controller, ProfilePic, ProfilePreview};
|
||||||
pub use relay::RelayView;
|
pub use relay::RelayView;
|
||||||
pub use side_panel::{DesktopSidePanel, SidePanelAction};
|
pub use side_panel::{DesktopSidePanel, SidePanelAction};
|
||||||
|
pub use thread::ThreadView;
|
||||||
|
pub use timeline::TimelineView;
|
||||||
pub use username::Username;
|
pub use username::Username;
|
||||||
|
|
||||||
use egui::Margin;
|
use egui::Margin;
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ fn render_note_contents(
|
|||||||
#[cfg(feature = "profiling")]
|
#[cfg(feature = "profiling")]
|
||||||
puffin::profile_function!();
|
puffin::profile_function!();
|
||||||
|
|
||||||
|
let selectable = options.has_selectable_text();
|
||||||
let images: Vec<String> = vec![];
|
let images: Vec<String> = vec![];
|
||||||
let mut inline_note: Option<(&[u8; 32], &str)> = None;
|
let mut inline_note: Option<(&[u8; 32], &str)> = None;
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ fn render_note_contents(
|
|||||||
BlockType::Text => {
|
BlockType::Text => {
|
||||||
#[cfg(feature = "profiling")]
|
#[cfg(feature = "profiling")]
|
||||||
puffin::profile_scope!("text contents");
|
puffin::profile_scope!("text contents");
|
||||||
ui.label(block.as_str());
|
ui.add(egui::Label::new(block.as_str()).selectable(selectable));
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
@@ -33,11 +33,17 @@ fn reply_desc(ui: &mut egui::Ui, txn: &Transaction, note_reply: &NoteReply, app:
|
|||||||
#[cfg(feature = "profiling")]
|
#[cfg(feature = "profiling")]
|
||||||
puffin::profile_function!();
|
puffin::profile_function!();
|
||||||
|
|
||||||
ui.add(Label::new(
|
let size = 10.0;
|
||||||
RichText::new("replying to")
|
let selectable = false;
|
||||||
.size(10.0)
|
|
||||||
.color(colors::GRAY_SECONDARY),
|
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() {
|
let reply = if let Some(reply) = note_reply.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) {
|
let reply_note = if let Ok(reply_note) = app.ndb.get_note_by_id(txn, reply.id) {
|
||||||
reply_note
|
reply_note
|
||||||
} else {
|
} else {
|
||||||
ui.add(Label::new(
|
ui.add(
|
||||||
RichText::new("a note")
|
Label::new(
|
||||||
.size(10.0)
|
RichText::new("a note")
|
||||||
.color(colors::GRAY_SECONDARY),
|
.size(size)
|
||||||
));
|
.color(colors::GRAY_SECONDARY),
|
||||||
|
)
|
||||||
|
.selectable(selectable),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if note_reply.is_reply_to_root() {
|
if note_reply.is_reply_to_root() {
|
||||||
// We're replying to the root, let's show this
|
// 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(
|
||||||
ui.add(Label::new(
|
ui::Mention::new(app, txn, reply_note.pubkey())
|
||||||
RichText::new("'s note")
|
.size(size)
|
||||||
.size(10.0)
|
.selectable(selectable),
|
||||||
.color(colors::GRAY_SECONDARY),
|
);
|
||||||
));
|
ui.add(
|
||||||
|
Label::new(
|
||||||
|
RichText::new("'s note")
|
||||||
|
.size(size)
|
||||||
|
.color(colors::GRAY_SECONDARY),
|
||||||
|
)
|
||||||
|
.selectable(selectable),
|
||||||
|
);
|
||||||
} else if let Some(root) = note_reply.root() {
|
} else if let Some(root) = note_reply.root() {
|
||||||
// replying to another post in a thread, not the 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 let Ok(root_note) = app.ndb.get_note_by_id(txn, root.id) {
|
||||||
if root_note.pubkey() == reply_note.pubkey() {
|
if root_note.pubkey() == reply_note.pubkey() {
|
||||||
// simply "replying to bob's note" when replying to bob in his thread
|
// 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(
|
||||||
ui.add(Label::new(
|
ui::Mention::new(app, txn, reply_note.pubkey())
|
||||||
RichText::new("'s note")
|
.size(size)
|
||||||
.size(10.0)
|
.selectable(selectable),
|
||||||
.color(colors::GRAY_SECONDARY),
|
);
|
||||||
));
|
ui.add(
|
||||||
|
Label::new(
|
||||||
|
RichText::new("'s note")
|
||||||
|
.size(size)
|
||||||
|
.color(colors::GRAY_SECONDARY),
|
||||||
|
)
|
||||||
|
.selectable(selectable),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// replying to bob in alice's thread
|
// replying to bob in alice's thread
|
||||||
|
|
||||||
ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0));
|
ui.add(
|
||||||
ui.add(Label::new(
|
ui::Mention::new(app, txn, reply_note.pubkey())
|
||||||
RichText::new("in").size(10.0).color(colors::GRAY_SECONDARY),
|
.size(size)
|
||||||
));
|
.selectable(selectable),
|
||||||
ui.add(ui::Mention::new(app, txn, root_note.pubkey()).size(10.0));
|
);
|
||||||
ui.add(Label::new(
|
ui.add(
|
||||||
RichText::new("'s thread")
|
Label::new(RichText::new("in").size(size).color(colors::GRAY_SECONDARY))
|
||||||
.size(10.0)
|
.selectable(selectable),
|
||||||
.color(colors::GRAY_SECONDARY),
|
);
|
||||||
));
|
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 {
|
} else {
|
||||||
ui.add(ui::Mention::new(app, txn, reply_note.pubkey()).size(10.0));
|
ui.add(
|
||||||
ui.add(Label::new(
|
ui::Mention::new(app, txn, reply_note.pubkey())
|
||||||
RichText::new("in someone's thread")
|
.size(size)
|
||||||
.size(10.0)
|
.selectable(selectable),
|
||||||
.color(colors::GRAY_SECONDARY),
|
);
|
||||||
));
|
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
|
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 {
|
pub fn wide(mut self, enable: bool) -> Self {
|
||||||
self.options_mut().set_wide(enable);
|
self.options_mut().set_wide(enable);
|
||||||
self
|
self
|
||||||
@@ -373,33 +420,13 @@ fn render_note_actionbar(
|
|||||||
note_key: NoteKey,
|
note_key: NoteKey,
|
||||||
) -> egui::InnerResponse<Option<BarAction>> {
|
) -> egui::InnerResponse<Option<BarAction>> {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let img_data = if ui.style().visuals.dark_mode {
|
let reply_resp = reply_button(ui, note_key);
|
||||||
egui::include_image!("../../../assets/icons/reply.png")
|
let thread_resp = thread_button(ui, note_key);
|
||||||
} else {
|
|
||||||
egui::include_image!("../../../assets/icons/reply-dark.png")
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.spacing_mut().button_padding = egui::vec2(0.0, 0.0);
|
if reply_resp.clicked() {
|
||||||
|
|
||||||
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() {
|
|
||||||
Some(BarAction::Reply)
|
Some(BarAction::Reply)
|
||||||
|
} else if thread_resp.clicked() {
|
||||||
|
Some(BarAction::OpenThread)
|
||||||
} else {
|
} else {
|
||||||
None
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,20 +6,45 @@ bitflags! {
|
|||||||
#[repr(transparent)]
|
#[repr(transparent)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct NoteOptions: u32 {
|
pub struct NoteOptions: u32 {
|
||||||
const actionbar = 0b00000001;
|
const actionbar = 0b00000001;
|
||||||
const note_previews = 0b00000010;
|
const note_previews = 0b00000010;
|
||||||
const small_pfp = 0b00000100;
|
const small_pfp = 0b00000100;
|
||||||
const medium_pfp = 0b00001000;
|
const medium_pfp = 0b00001000;
|
||||||
const wide = 0b00010000;
|
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 {
|
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]
|
#[inline]
|
||||||
pub fn has_actionbar(self) -> bool {
|
pub fn has_actionbar(self) -> bool {
|
||||||
(self & NoteOptions::actionbar) == NoteOptions::actionbar
|
(self & NoteOptions::actionbar) == NoteOptions::actionbar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn has_selectable_text(self) -> bool {
|
||||||
|
(self & NoteOptions::selectable_text) == NoteOptions::selectable_text
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn has_note_previews(self) -> bool {
|
pub fn has_note_previews(self) -> bool {
|
||||||
(self & NoteOptions::note_previews) == NoteOptions::note_previews
|
(self & NoteOptions::note_previews) == NoteOptions::note_previews
|
||||||
@@ -58,40 +83,4 @@ impl NoteOptions {
|
|||||||
*self &= !NoteOptions::wide;
|
*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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ impl<'app, 'd> PostView<'app, 'd> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let buffer = &mut self.draft_source.draft(&mut self.app.drafts).buffer;
|
let buffer = &mut self.draft_source.draft(&mut self.app.drafts).buffer;
|
||||||
|
|
||||||
let response = ui.add_sized(
|
let response = ui.add_sized(
|
||||||
ui.available_size(),
|
ui.available_size(),
|
||||||
TextEdit::multiline(buffer)
|
TextEdit::multiline(buffer)
|
||||||
|
|||||||
139
src/ui/thread.rs
Normal file
139
src/ui/thread.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/ui/timeline.rs
Normal file
248
src/ui/timeline.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user