Merge initial threads

This commit is contained in:
William Casarin 2024-08-16 11:51:42 -07:00
commit 8c458f8f78
18 changed files with 1264 additions and 480 deletions

2
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -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, &note)
.clone();
let _ = get_unknown_note_ids(&damus.ndb, &cached_note, txn, &note, 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;
}
}

View File

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

View File

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

View File

@ -27,6 +27,7 @@ pub mod relay_pool_manager;
mod result;
mod route;
mod test_data;
mod thread;
mod time;
mod timecache;
mod timeline;

View File

@ -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, &note)
.reply
.borrow(note.tags())
.root()
.map_or_else(|| selected_note_id, |nr| nr.id)
}

189
src/thread.rs Normal file
View 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 {
//}
}

View File

@ -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, &note)
.clone();
let _ = get_unknown_note_ids(&app.ndb, &cached_note, txn, &note, 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, &note)
.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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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, &note);
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, &note)
.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
View 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, &note)
.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)
}