mirror of
https://github.com/damus-io/notedeck.git
synced 2024-09-16 11:53:30 +00:00
Merge initial threads
This commit is contained in:
commit
8c458f8f78
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||
|
@ -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"
|
||||
|
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 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);
|
||||
}
|
||||
}
|
||||
|
229
src/app.rs
229
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
39
src/error.rs
39
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")
|
||||
|
@ -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()
|
||||
|
@ -27,6 +27,7 @@ pub mod relay_pool_manager;
|
||||
mod result;
|
||||
mod route;
|
||||
mod test_data;
|
||||
mod thread;
|
||||
mod time;
|
||||
mod timecache;
|
||||
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;
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
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::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,
|
||||
|
@ -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| {
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
_ => {
|
||||
|
@ -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")
|
||||
};
|
||||
let reply_resp = reply_button(ui, note_key);
|
||||
let thread_resp = thread_button(ui, note_key);
|
||||
|
||||
ui.spacing_mut().button_padding = egui::vec2(0.0, 0.0);
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -6,20 +6,45 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user