From 56dd88b3a21c00fb526296d277ac6bfb807ae11c Mon Sep 17 00:00:00 2001 From: kernelkind Date: Tue, 29 Oct 2024 17:23:40 -0400 Subject: [PATCH 01/15] initial column storage Signed-off-by: kernelkind --- src/account_manager.rs | 3 +- src/app.rs | 41 +++++++++++++++----- src/args.rs | 7 +--- src/column.rs | 88 +++++++++++++++++++++++++++++++++++++++++- src/nav.rs | 7 +++- src/route.rs | 3 +- src/storage/columns.rs | 60 ++++++++++++++++++++++++++++ src/storage/mod.rs | 2 + src/timeline/kind.rs | 7 ++-- src/timeline/mod.rs | 22 ++++++++++- src/timeline/route.rs | 2 +- 11 files changed, 217 insertions(+), 25 deletions(-) create mode 100644 src/storage/columns.rs diff --git a/src/account_manager.rs b/src/account_manager.rs index 7551d1a6..42d2e517 100644 --- a/src/account_manager.rs +++ b/src/account_manager.rs @@ -2,6 +2,7 @@ use std::cmp::Ordering; use enostr::{FilledKeypair, FullKeypair, Keypair}; use nostrdb::Ndb; +use serde::{Deserialize, Serialize}; use crate::{ column::Columns, @@ -32,7 +33,7 @@ pub enum AccountsRouteResponse { AddAccount(AccountLoginResponse), } -#[derive(Debug, Eq, PartialEq, Clone, Copy)] +#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)] pub enum AccountsRoute { Accounts, AddAccount, diff --git a/src/app.rs b/src/app.rs index 0f475170..4c51b41b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::{ notecache::{CachedNote, NoteCache}, notes_holder::NotesHolderStorage, profile::Profile, - storage::{Directory, FileKeyStorage, KeyStorageType}, + storage::{self, Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, support::Support, thread::Thread, @@ -727,12 +727,28 @@ impl Damus { .map(|a| a.pubkey.bytes()); let ndb = Ndb::new(&dbpath, &config).expect("ndb"); - let mut columns: Columns = Columns::new(); - for col in parsed_args.columns { - if let Some(timeline) = col.into_timeline(&ndb, account) { - columns.add_new_timeline_column(timeline); + let mut columns = if parsed_args.columns.is_empty() { + if let Some(serializable_columns) = storage::load_columns() { + info!("Using columns from disk"); + serializable_columns.into_columns(&ndb, account) + } else { + info!("Could not load columns from disk"); + Columns::new() } - } + } else { + info!( + "Using columns from command line arguments: {:?}", + parsed_args.columns + ); + let mut columns: Columns = Columns::new(); + for col in parsed_args.columns { + if let Some(timeline) = col.into_timeline(&ndb, account) { + columns.add_new_timeline_column(timeline); + } + } + + columns + }; let debug = parsed_args.debug; @@ -971,8 +987,8 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { //let routes = app.timelines[0].routes.clone(); main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { - if !app.columns.columns().is_empty() { - nav::render_nav(0, app, ui); + if !app.columns.columns().is_empty() && nav::render_nav(0, app, ui) { + storage::save_columns(app.columns.as_serializable_columns()); } }); } @@ -1049,10 +1065,13 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { ); }); + let mut columns_changed = false; for col_index in 0..app.columns.num_columns() { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - nav::render_nav(col_index, app, ui); + if nav::render_nav(col_index, app, ui) { + columns_changed = true; + } // vertical line ui.painter().vline( @@ -1064,6 +1083,10 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } + + if columns_changed { + storage::save_columns(app.columns.as_serializable_columns()); + } }); } diff --git a/src/args.rs b/src/args.rs index 9ac7d923..8170b149 100644 --- a/src/args.rs +++ b/src/args.rs @@ -219,18 +219,13 @@ impl Args { i += 1; } - if res.columns.is_empty() { - let ck = TimelineKind::contact_list(PubkeySource::DeckAuthor); - info!("No columns set, setting up defaults: {:?}", ck); - res.columns.push(ArgColumn::Timeline(ck)); - } - res } } /// A way to define columns from the commandline. Can be column kinds or /// generic queries +#[derive(Debug)] pub enum ArgColumn { Timeline(TimelineKind), Generic(Vec), diff --git a/src/column.rs b/src/column.rs index 0b986323..3b513d05 100644 --- a/src/column.rs +++ b/src/column.rs @@ -1,10 +1,13 @@ use crate::route::{Route, Router}; -use crate::timeline::{Timeline, TimelineId}; +use crate::timeline::{SerializableTimeline, Timeline, TimelineId, TimelineRoute}; use indexmap::IndexMap; +use nostrdb::Ndb; +use serde::{Deserialize, Deserializer, Serialize}; use std::iter::Iterator; use std::sync::atomic::{AtomicU32, Ordering}; -use tracing::warn; +use tracing::{error, warn}; +#[derive(Clone)] pub struct Column { router: Router, } @@ -24,6 +27,28 @@ impl Column { } } +impl serde::Serialize for Column { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.router.routes().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Column { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let routes = Vec::::deserialize(deserializer)?; + + Ok(Column { + router: Router::new(routes), + }) + } +} + #[derive(Default)] pub struct Columns { /// Columns are simply routers into settings, timelines, etc @@ -68,6 +93,10 @@ impl Columns { UIDS.fetch_add(1, Ordering::Relaxed) } + pub fn add_column_at(&mut self, column: Column, index: u32) { + self.columns.insert(index, column); + } + pub fn add_column(&mut self, column: Column) { self.columns.insert(Self::get_new_id(), column); } @@ -194,4 +223,59 @@ impl Columns { } } } + + pub fn as_serializable_columns(&self) -> SerializableColumns { + SerializableColumns { + columns: self.columns.values().cloned().collect(), + timelines: self + .timelines + .values() + .map(|t| t.as_serializable_timeline()) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct SerializableColumns { + pub columns: Vec, + pub timelines: Vec, +} + +impl SerializableColumns { + pub fn into_columns(self, ndb: &Ndb, deck_pubkey: Option<&[u8; 32]>) -> Columns { + let mut columns = Columns::default(); + + for column in self.columns { + let id = Columns::get_new_id(); + let mut routes = Vec::new(); + for route in column.router.routes() { + match route { + Route::Timeline(TimelineRoute::Timeline(timeline_id)) => { + if let Some(serializable_tl) = + self.timelines.iter().find(|tl| tl.id == *timeline_id) + { + let tl = serializable_tl.clone().into_timeline(ndb, deck_pubkey); + if let Some(tl) = tl { + routes.push(Route::Timeline(TimelineRoute::Timeline(tl.id))); + columns.timelines.insert(id, tl); + } else { + error!("Problem deserializing timeline {:?}", serializable_tl); + } + } + } + Route::Timeline(TimelineRoute::Thread(_thread)) => { + // TODO: open thread before pushing route + } + Route::Profile(_profile) => { + // TODO: open profile before pushing route + } + _ => routes.push(*route), + } + } + columns.add_column_at(Column::new(routes), id); + } + + columns + } } diff --git a/src/nav.rs b/src/nav.rs index efe1283a..5f8f2141 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -27,7 +27,8 @@ use egui_nav::{Nav, NavAction, TitleBarResponse}; use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; -pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { +pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { + let mut col_changed = false; let col_id = app.columns.get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router in egui-nav directly let routes = app @@ -201,12 +202,14 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { pubkey.bytes(), ); } + col_changed = true; } else if let Some(NavAction::Navigated) = nav_response.action { let cur_router = app.columns_mut().column_mut(col).router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { cur_router.remove_previous_route(); } + col_changed = true; } if let Some(title_response) = nav_response.title_response { @@ -220,6 +223,8 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { } } } + + col_changed } fn unsubscribe_timeline(ndb: &Ndb, timeline: &Timeline) { diff --git a/src/route.rs b/src/route.rs index 27923fb3..0f7a7a12 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,5 +1,6 @@ use enostr::{NoteId, Pubkey}; use nostrdb::Ndb; +use serde::{Deserialize, Serialize}; use std::fmt::{self}; use crate::{ @@ -10,7 +11,7 @@ use crate::{ }; /// App routing. These describe different places you can go inside Notedeck. -#[derive(Clone, Copy, Eq, PartialEq, Debug)] +#[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)] pub enum Route { Timeline(TimelineRoute), Accounts(AccountsRoute), diff --git a/src/storage/columns.rs b/src/storage/columns.rs new file mode 100644 index 00000000..1fe542f8 --- /dev/null +++ b/src/storage/columns.rs @@ -0,0 +1,60 @@ +use tracing::{error, info}; + +use crate::column::SerializableColumns; + +use super::{write_file, DataPaths, Directory}; + +static COLUMNS_FILE: &str = "columns.json"; + +pub fn save_columns(columns: SerializableColumns) { + let serialized_columns = match serde_json::to_string(&columns) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize columns: {}", e); + return; + } + }; + + let data_path = match DataPaths::Setting.get_path() { + Ok(s) => s, + Err(e) => { + error!("Could not get data path: {}", e); + return; + } + }; + + if let Err(e) = write_file(&data_path, COLUMNS_FILE.to_string(), &serialized_columns) { + error!("Could not write columns to file {}: {}", COLUMNS_FILE, e); + } else { + info!("Successfully wrote columns to {}", COLUMNS_FILE); + } +} + +pub fn load_columns() -> Option { + let data_path = match DataPaths::Setting.get_path() { + Ok(s) => s, + Err(e) => { + error!("Could not get data path: {}", e); + return None; + } + }; + + let columns_string = match Directory::new(data_path).get_file(COLUMNS_FILE.to_owned()) { + Ok(s) => s, + Err(e) => { + error!("Could not read columns from file {}: {}", COLUMNS_FILE, e); + return None; + } + }; + + match serde_json::from_str::(&columns_string) { + Ok(s) => { + info!("Successfully loaded columns from {}", COLUMNS_FILE); + Some(s) + } + Err(e) => { + error!("Could not deserialize columns: {}", e); + None + } + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 7eb4ce7e..1a5a9e22 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,7 +1,9 @@ +mod columns; #[cfg(any(target_os = "linux", target_os = "macos"))] mod file_key_storage; mod file_storage; +pub use columns::{load_columns, save_columns}; pub use file_key_storage::FileKeyStorage; pub use file_storage::write_file; pub use file_storage::DataPaths; diff --git a/src/timeline/kind.rs b/src/timeline/kind.rs index c48a9279..3a281dd3 100644 --- a/src/timeline/kind.rs +++ b/src/timeline/kind.rs @@ -5,16 +5,17 @@ use crate::timeline::Timeline; use crate::ui::profile::preview::get_profile_displayname_string; use enostr::{Filter, Pubkey}; use nostrdb::{Ndb, Transaction}; +use serde::{Deserialize, Serialize}; use std::fmt::Display; use tracing::{error, warn}; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub enum PubkeySource { Explicit(Pubkey), DeckAuthor, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum ListKind { Contact(PubkeySource), } @@ -27,7 +28,7 @@ pub enum ListKind { /// - filter /// - ... etc /// -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub enum TimelineKind { List(ListKind), diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index 72593375..d92e0f97 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; use nostrdb::{Ndb, Note, Subscription, Transaction}; +use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::hash::Hash; use std::rc::Rc; @@ -21,7 +22,7 @@ pub mod route; pub use kind::{PubkeySource, TimelineKind}; pub use route::TimelineRoute; -#[derive(Debug, Hash, Copy, Clone, Eq, PartialEq)] +#[derive(Debug, Hash, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct TimelineId(u32); impl TimelineId { @@ -177,6 +178,18 @@ pub struct Timeline { pub subscription: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SerializableTimeline { + pub id: TimelineId, + pub kind: TimelineKind, +} + +impl SerializableTimeline { + pub fn into_timeline(self, ndb: &Ndb, deck_user_pubkey: Option<&[u8; 32]>) -> Option { + self.kind.into_timeline(ndb, deck_user_pubkey) + } +} + impl Timeline { /// Create a timeline from a contact list pub fn contact_list(contact_list: &Note, pk_src: PubkeySource) -> Result { @@ -312,6 +325,13 @@ impl Timeline { Ok(()) } + + pub fn as_serializable_timeline(&self) -> SerializableTimeline { + SerializableTimeline { + id: self.id, + kind: self.kind.clone(), + } + } } pub enum MergeKind { diff --git a/src/timeline/route.rs b/src/timeline/route.rs index 073479c6..059989f0 100644 --- a/src/timeline/route.rs +++ b/src/timeline/route.rs @@ -21,7 +21,7 @@ use crate::{ use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, Transaction}; -#[derive(Debug, Eq, PartialEq, Clone, Copy)] +#[derive(Debug, Eq, PartialEq, Clone, Copy, serde::Serialize, serde::Deserialize)] pub enum TimelineRoute { Timeline(TimelineId), Thread(NoteId), From ee5dd5426fbb9b23329ad122beb76ee1baed9708 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Wed, 30 Oct 2024 13:45:22 -0400 Subject: [PATCH 02/15] tmp remove DeckAuthor columns we don't yet have logic for handling switching 'deck authors' and this is causing two problems: 1. the column title isn't renamed when the selected account is changed 2. when saving a deck author column to disk and the account is switched beforehand, it switches to the current deck author's column Signed-off-by: kernelkind --- src/ui/add_column.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index 298addcc..a25d4b83 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -178,11 +178,7 @@ impl<'a> AddColumnView<'a> { }); if let Some(acc) = self.cur_account { - let source = if acc.secret_key.is_some() { - PubkeySource::DeckAuthor - } else { - PubkeySource::Explicit(acc.pubkey) - }; + let source = PubkeySource::Explicit(acc.pubkey); vec.push(ColumnOptionData { title: "Home timeline", From 529b76094c45e011ce4770df8058aafb7e184475 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sat, 2 Nov 2024 21:39:47 -0400 Subject: [PATCH 03/15] init external notifs column Signed-off-by: kernelkind --- src/column.rs | 4 +- src/nav.rs | 20 ++---- src/route.rs | 26 ++++--- src/ui/add_column.rs | 160 +++++++++++++++++++++++++++++++++++++++++-- src/ui/side_panel.rs | 6 +- 5 files changed, 186 insertions(+), 30 deletions(-) diff --git a/src/column.rs b/src/column.rs index 0b986323..bd0acdbb 100644 --- a/src/column.rs +++ b/src/column.rs @@ -61,7 +61,9 @@ impl Columns { } pub fn new_column_picker(&mut self) { - self.add_column(Column::new(vec![Route::AddColumn])); + self.add_column(Column::new(vec![Route::AddColumn( + crate::ui::add_column::AddColumnRoute::Base, + )])); } fn get_new_id() -> u32 { diff --git a/src/nav.rs b/src/nav.rs index efe1283a..266970e3 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -13,7 +13,7 @@ use crate::{ }, ui::{ self, - add_column::{AddColumnResponse, AddColumnView}, + add_column::render_add_column_routes, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, note::PostAction, support::SupportView, @@ -101,19 +101,9 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { None } - Route::AddColumn => { - let resp = - AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).ui(ui); - - if let Some(resp) = resp { - match resp { - AddColumnResponse::Timeline(timeline) => { - let id = timeline.id; - app.columns_mut().add_timeline_to_column(col, timeline); - app.subscribe_new_timeline(id); - } - }; - } + Route::AddColumn(route) => { + render_add_column_routes(ui, app, col, route); + None } @@ -205,7 +195,7 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { let cur_router = app.columns_mut().column_mut(col).router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { - cur_router.remove_previous_route(); + cur_router.remove_previous_routes(); } } diff --git a/src/route.rs b/src/route.rs index 27923fb3..4d96188f 100644 --- a/src/route.rs +++ b/src/route.rs @@ -6,7 +6,10 @@ use crate::{ account_manager::AccountsRoute, column::Columns, timeline::{TimelineId, TimelineRoute}, - ui::profile::preview::{get_note_users_displayname_string, get_profile_displayname_string}, + ui::{ + add_column::AddColumnRoute, + profile::preview::{get_note_users_displayname_string, get_profile_displayname_string}, + }, }; /// App routing. These describe different places you can go inside Notedeck. @@ -16,7 +19,7 @@ pub enum Route { Accounts(AccountsRoute), Relays, ComposeNote, - AddColumn, + AddColumn(AddColumnRoute), Profile(Pubkey), Support, } @@ -97,7 +100,13 @@ impl Route { AccountsRoute::AddAccount => "Add Account".to_owned(), }, Route::ComposeNote => "Compose Note".to_owned(), - Route::AddColumn => "Add Column".to_owned(), + Route::AddColumn(c) => match c { + AddColumnRoute::Base => "Add Column".to_owned(), + AddColumnRoute::UndecidedNotification => "Add Notifications Column".to_owned(), + AddColumnRoute::ExternalNotification => { + "Add External Notifications Column".to_owned() + } + }, Route::Profile(pubkey) => { format!("{}'s Profile", get_profile_displayname_string(ndb, pubkey)) } @@ -142,7 +151,7 @@ impl Router { self.routes.push(route); } - // Route to R. Then when it is successfully placed, should call `remove_previous_route` + // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes pub fn route_to_replaced(&mut self, route: R) { self.navigating = true; self.replacing = true; @@ -167,14 +176,15 @@ impl Router { self.routes.pop() } - pub fn remove_previous_route(&mut self) -> Option { + pub fn remove_previous_routes(&mut self) { let num_routes = self.routes.len(); if num_routes <= 1 { - return None; + return; } + self.returning = false; self.replacing = false; - Some(self.routes.remove(num_routes - 2)) + self.routes.drain(..num_routes - 1); } pub fn is_replacing(&self) -> bool { @@ -208,7 +218,7 @@ impl fmt::Display for Route { }, Route::ComposeNote => write!(f, "Compose Note"), - Route::AddColumn => write!(f, "Add Column"), + Route::AddColumn(_) => write!(f, "Add Column"), Route::Profile(_) => write!(f, "Profile"), Route::Support => write!(f, "Support"), } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index 298addcc..477528f8 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -1,26 +1,45 @@ -use egui::{pos2, vec2, Color32, FontId, ImageSource, Pos2, Rect, Separator, Ui}; +use egui::{pos2, vec2, Color32, FontId, ImageSource, Pos2, Rect, RichText, Separator, Ui}; use nostrdb::Ndb; +use tracing::{error, info}; use crate::{ app_style::{get_font_size, NotedeckTextStyle}, + key_parsing::perform_key_retrieval, timeline::{PubkeySource, Timeline, TimelineKind}, ui::anim::ICON_EXPANSION_MULTIPLE, user_account::UserAccount, + Damus, }; use super::anim::AnimationHelper; pub enum AddColumnResponse { Timeline(Timeline), + UndecidedNotification, + ExternalNotification, +} + +pub enum NotificationColumnType { + Home, + External, } #[derive(Clone, Debug)] enum AddColumnOption { Universe, + UndecidedNotification, + ExternalNotification, Notification(PubkeySource), Home(PubkeySource), } +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub enum AddColumnRoute { + Base, + UndecidedNotification, + ExternalNotification, +} + impl AddColumnOption { pub fn take_as_response( self, @@ -34,11 +53,15 @@ impl AddColumnOption { AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey) .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) .map(AddColumnResponse::Timeline), + AddColumnOption::UndecidedNotification => { + Some(AddColumnResponse::UndecidedNotification) + } AddColumnOption::Home(pubkey) => { let tlk = TimelineKind::contact_list(pubkey); tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) .map(AddColumnResponse::Timeline) } + AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification), } } } @@ -55,7 +78,21 @@ impl<'a> AddColumnView<'a> { pub fn ui(&mut self, ui: &mut Ui) -> Option { let mut selected_option: Option = None; - for column_option_data in self.get_column_options() { + for column_option_data in self.get_base_options() { + let option = column_option_data.option.clone(); + if self.column_option_ui(ui, column_option_data).clicked() { + selected_option = option.take_as_response(self.ndb, self.cur_account); + } + + ui.add(Separator::default().spacing(0.0)); + } + + selected_option + } + + fn notifications_ui(&mut self, ui: &mut Ui) -> Option { + let mut selected_option: Option = None; + for column_option_data in self.get_notifications_options() { let option = column_option_data.option.clone(); if self.column_option_ui(ui, column_option_data).clicked() { selected_option = option.take_as_response(self.ndb, self.cur_account); @@ -67,6 +104,51 @@ impl<'a> AddColumnView<'a> { selected_option } + fn external_notification_ui(&mut self, ui: &mut Ui) -> Option { + ui.label(RichText::new("External Notification").heading()); + ui.label("Paste the user's npub that you would like to have a notifications column for:"); + + let id = ui.id().with("external_notif"); + let mut text = ui.ctx().data_mut(|data| { + let text = data.get_temp_mut_or_insert_with(id, String::new); + text.clone() + }); + ui.text_edit_singleline(&mut text); + ui.ctx().data_mut(|d| d.insert_temp(id, text.clone())); + + if ui.button("Validate").clicked() { + if let Some(payload) = perform_key_retrieval(&text).ready() { + match payload { + Ok(keypair) => { + info!( + "Successfully retrieved external notification keypair {}", + keypair.pubkey + ); + if let Some(resp) = + AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey)) + .take_as_response(self.ndb, self.cur_account) + { + Some(resp) + } else { + error!("Failed to get timeline column"); + None + } + } + Err(_) => { + info!("User did not enter a valid npub or nip05"); + ui.colored_label(Color32::RED, "Please enter a valid npub or nip05"); + None + } + } + } else { + ui.spinner(); + None + } + } else { + None + } + } + fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { let icon_padding = 8.0; let min_icon_width = 32.0; @@ -168,7 +250,7 @@ impl<'a> AddColumnView<'a> { helper.take_animation_response() } - fn get_column_options(&self) -> Vec { + fn get_base_options(&self) -> Vec { let mut vec = Vec::new(); vec.push(ColumnOptionData { title: "Universe", @@ -190,14 +272,42 @@ impl<'a> AddColumnView<'a> { icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"), option: AddColumnOption::Home(source.clone()), }); + } + vec.push(ColumnOptionData { + title: "Notifications", + description: "Stay up to date with notifications and mentions", + icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), + option: AddColumnOption::UndecidedNotification, + }); + + vec + } + + fn get_notifications_options(&self) -> Vec { + let mut vec = Vec::new(); + + if let Some(acc) = self.cur_account { + let source = if acc.secret_key.is_some() { + PubkeySource::DeckAuthor + } else { + PubkeySource::Explicit(acc.pubkey) + }; + vec.push(ColumnOptionData { - title: "Notifications", - description: "Stay up to date with notifications and mentions", + title: "Your Notifications", + description: "Stay up to date with your notifications and mentions", icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), option: AddColumnOption::Notification(source), }); } + vec.push(ColumnOptionData { + title: "Someone else's Notifications", + description: "Stay up to date with someone else's notifications and mentions", + icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), + option: AddColumnOption::ExternalNotification, + }); + vec } } @@ -209,6 +319,46 @@ struct ColumnOptionData { option: AddColumnOption, } +pub fn render_add_column_routes( + ui: &mut egui::Ui, + app: &mut Damus, + col: usize, + route: &AddColumnRoute, +) { + let resp = match route { + AddColumnRoute::Base => { + AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).ui(ui) + } + AddColumnRoute::UndecidedNotification => { + AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).notifications_ui(ui) + } + AddColumnRoute::ExternalNotification => { + AddColumnView::new(&app.ndb, app.accounts.get_selected_account()) + .external_notification_ui(ui) + } + }; + + if let Some(resp) = resp { + match resp { + AddColumnResponse::Timeline(timeline) => { + let id = timeline.id; + app.columns_mut().add_timeline_to_column(col, timeline); + app.subscribe_new_timeline(id); + } + AddColumnResponse::UndecidedNotification => { + app.columns_mut().column_mut(col).router_mut().route_to( + crate::route::Route::AddColumn(AddColumnRoute::UndecidedNotification), + ); + } + AddColumnResponse::ExternalNotification => { + app.columns_mut().column_mut(col).router_mut().route_to( + crate::route::Route::AddColumn(AddColumnRoute::ExternalNotification), + ); + } + }; + } +} + mod preview { use crate::{ test_data, diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs index 7cefedbe..9f311421 100644 --- a/src/ui/side_panel.rs +++ b/src/ui/side_panel.rs @@ -196,7 +196,11 @@ impl<'a> DesktopSidePanel<'a> { } } SidePanelAction::Columns => { - if router.routes().iter().any(|&r| r == Route::AddColumn) { + if router + .routes() + .iter() + .any(|&r| matches!(r, Route::AddColumn(_))) + { router.go_back(); } else { columns.new_column_picker(); From 412ba9b5655eabb1573520da27d1e3a12cff91d0 Mon Sep 17 00:00:00 2001 From: kernelkind Date: Sat, 2 Nov 2024 22:16:04 -0400 Subject: [PATCH 04/15] use AcquireKeyState for AddColumn Signed-off-by: kernelkind --- src/account_manager.rs | 4 +- src/key_parsing.rs | 24 +++--- src/login_manager.rs | 54 +++++++------- src/ui/account_login_view.rs | 24 +++--- src/ui/add_column.rs | 139 +++++++++++++++++++++-------------- src/view_state.rs | 9 ++- 6 files changed, 144 insertions(+), 110 deletions(-) diff --git a/src/account_manager.rs b/src/account_manager.rs index 7551d1a6..80f73dad 100644 --- a/src/account_manager.rs +++ b/src/account_manager.rs @@ -6,7 +6,7 @@ use nostrdb::Ndb; use crate::{ column::Columns, imgcache::ImageCache, - login_manager::LoginState, + login_manager::AcquireKeyState, route::{Route, Router}, storage::{KeyStorageResponse, KeyStorageType}, ui::{ @@ -47,7 +47,7 @@ pub fn render_accounts_route( columns: &mut Columns, img_cache: &mut ImageCache, accounts: &mut AccountManager, - login_state: &mut LoginState, + login_state: &mut AcquireKeyState, route: AccountsRoute, ) { let router = columns.column_mut(col).router_mut(); diff --git a/src/key_parsing.rs b/src/key_parsing.rs index a6968fae..f333e0f5 100644 --- a/src/key_parsing.rs +++ b/src/key_parsing.rs @@ -8,21 +8,23 @@ use reqwest::{Request, Response}; use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq)] -pub enum LoginError { +pub enum AcquireKeyError { InvalidKey, Nip05Failed(String), } -impl std::fmt::Display for LoginError { +impl std::fmt::Display for AcquireKeyError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - LoginError::InvalidKey => write!(f, "The inputted key is invalid."), - LoginError::Nip05Failed(e) => write!(f, "Failed to get pubkey from Nip05 address: {e}"), + AcquireKeyError::InvalidKey => write!(f, "The inputted key is invalid."), + AcquireKeyError::Nip05Failed(e) => { + write!(f, "Failed to get pubkey from Nip05 address: {e}") + } } } } -impl std::error::Error for LoginError {} +impl std::error::Error for AcquireKeyError {} #[derive(Deserialize, Serialize)] pub struct Nip05Result { @@ -95,9 +97,9 @@ fn retrieving_nip05_pubkey(key: &str) -> bool { key.contains('@') } -pub fn perform_key_retrieval(key: &str) -> Promise> { +pub fn perform_key_retrieval(key: &str) -> Promise> { let key_string = String::from(key); - Promise::spawn_async(async move { get_login_key(&key_string).await }) + Promise::spawn_async(async move { get_key(&key_string).await }) } /// Attempts to turn a string slice key from the user into a Nostr-Sdk Keypair object. @@ -108,7 +110,7 @@ pub fn perform_key_retrieval(key: &str) -> Promise> /// - Private hex key: "5dab..." /// - NIP-05 address: "example@nostr.com" /// -pub async fn get_login_key(key: &str) -> Result { +pub async fn get_key(key: &str) -> Result { let tmp_key: &str = if let Some(stripped) = key.strip_prefix('@') { stripped } else { @@ -118,7 +120,7 @@ pub async fn get_login_key(key: &str) -> Result { if retrieving_nip05_pubkey(tmp_key) { match get_nip05_pubkey(tmp_key).await { Ok(pubkey) => Ok(Keypair::only_pubkey(pubkey)), - Err(e) => Err(LoginError::Nip05Failed(e.to_string())), + Err(e) => Err(AcquireKeyError::Nip05Failed(e.to_string())), } } else if let Ok(pubkey) = Pubkey::try_from_bech32_string(tmp_key, true) { Ok(Keypair::only_pubkey(pubkey)) @@ -127,7 +129,7 @@ pub async fn get_login_key(key: &str) -> Result { } else if let Ok(secret_key) = SecretKey::from_str(tmp_key) { Ok(Keypair::from_secret(secret_key)) } else { - Err(LoginError::InvalidKey) + Err(AcquireKeyError::InvalidKey) } } @@ -141,7 +143,7 @@ mod tests { let pubkey_str = "npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s"; let expected_pubkey = Pubkey::try_from_bech32_string(pubkey_str, false).expect("Should not have errored."); - let login_key_result = get_login_key(pubkey_str).await; + let login_key_result = get_key(pubkey_str).await; assert_eq!(Ok(Keypair::only_pubkey(expected_pubkey)), login_key_result); } diff --git a/src/login_manager.rs b/src/login_manager.rs index 2971286c..94ea9435 100644 --- a/src/login_manager.rs +++ b/src/login_manager.rs @@ -1,47 +1,47 @@ use crate::key_parsing::perform_key_retrieval; -use crate::key_parsing::LoginError; +use crate::key_parsing::AcquireKeyError; use egui::{TextBuffer, TextEdit}; use enostr::Keypair; use poll_promise::Promise; -/// The UI view interface to log in to a nostr account. +/// The state data for acquiring a nostr key #[derive(Default)] -pub struct LoginState { - login_key: String, - promise_query: Option<(String, Promise>)>, - error: Option, +pub struct AcquireKeyState { + desired_key: String, + promise_query: Option<(String, Promise>)>, + error: Option, key_on_error: Option, should_create_new: bool, } -impl<'a> LoginState { +impl<'a> AcquireKeyState { pub fn new() -> Self { - LoginState::default() + AcquireKeyState::default() } - /// Get the textedit for the login UI without exposing the key variable - pub fn get_login_textedit( + /// Get the textedit for the UI without exposing the key variable + pub fn get_acquire_textedit( &'a mut self, textedit_closure: fn(&'a mut dyn TextBuffer) -> TextEdit<'a>, ) -> TextEdit<'a> { - textedit_closure(&mut self.login_key) + textedit_closure(&mut self.desired_key) } - /// User pressed the 'login' button - pub fn apply_login(&'a mut self) { + /// User pressed the 'acquire' button + pub fn apply_acquire(&'a mut self) { let new_promise = match &self.promise_query { Some((query, _)) => { - if query != &self.login_key { - Some(perform_key_retrieval(&self.login_key)) + if query != &self.desired_key { + Some(perform_key_retrieval(&self.desired_key)) } else { None } } - None => Some(perform_key_retrieval(&self.login_key)), + None => Some(perform_key_retrieval(&self.desired_key)), }; if let Some(new_promise) = new_promise { - self.promise_query = Some((self.login_key.clone(), new_promise)); + self.promise_query = Some((self.desired_key.clone(), new_promise)); } } @@ -51,9 +51,9 @@ impl<'a> LoginState { } /// Whether to indicate to the user that a login error occured - pub fn check_for_error(&'a mut self) -> Option<&'a LoginError> { + pub fn check_for_error(&'a mut self) -> Option<&'a AcquireKeyError> { if let Some(error_key) = &self.key_on_error { - if self.login_key != *error_key { + if self.desired_key != *error_key { self.error = None; self.key_on_error = None; } @@ -73,7 +73,7 @@ impl<'a> LoginState { } Err(e) => { self.error = Some(e); - self.key_on_error = Some(self.login_key.clone()); + self.key_on_error = Some(self.desired_key.clone()); } }; } @@ -100,7 +100,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_retrieve_key() { - let mut manager = LoginState::new(); + let mut manager = AcquireKeyState::new(); let expected_str = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681"; let expected_key = Keypair::only_pubkey(Pubkey::from_hex(expected_str).unwrap()); @@ -110,21 +110,21 @@ mod tests { let cur_time = start_time.elapsed(); if cur_time < Duration::from_millis(10u64) { - let _ = manager.get_login_textedit(|text| { + let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text("test", 0); egui::TextEdit::singleline(text) }); - manager.apply_login(); + manager.apply_acquire(); } else if cur_time < Duration::from_millis(30u64) { - let _ = manager.get_login_textedit(|text| { + let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text("test2", 0); egui::TextEdit::singleline(text) }); - manager.apply_login(); + manager.apply_acquire(); } else { - let _ = manager.get_login_textedit(|text| { + let _ = manager.get_acquire_textedit(|text| { text.clear(); text.insert_text( "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", @@ -132,7 +132,7 @@ mod tests { ); egui::TextEdit::singleline(text) }); - manager.apply_login(); + manager.apply_acquire(); } if let Some(key) = manager.check_for_successful_login() { diff --git a/src/ui/account_login_view.rs b/src/ui/account_login_view.rs index 9b0ba996..b864069a 100644 --- a/src/ui/account_login_view.rs +++ b/src/ui/account_login_view.rs @@ -1,13 +1,13 @@ use crate::app_style::NotedeckTextStyle; -use crate::key_parsing::LoginError; -use crate::login_manager::LoginState; +use crate::key_parsing::AcquireKeyError; +use crate::login_manager::AcquireKeyState; use crate::ui::{Preview, PreviewConfig, View}; use egui::TextEdit; use egui::{Align, Button, Color32, Frame, InnerResponse, Margin, RichText, Vec2}; use enostr::Keypair; pub struct AccountLoginView<'a> { - manager: &'a mut LoginState, + manager: &'a mut AcquireKeyState, } pub enum AccountLoginResponse { @@ -16,7 +16,7 @@ pub enum AccountLoginResponse { } impl<'a> AccountLoginView<'a> { - pub fn new(state: &'a mut LoginState) -> Self { + pub fn new(state: &'a mut AcquireKeyState) -> Self { AccountLoginView { manager: state } } @@ -43,7 +43,7 @@ impl<'a> AccountLoginView<'a> { self.loading_and_error(ui); if ui.add(login_button()).clicked() { - self.manager.apply_login(); + self.manager.apply_acquire(); } }); @@ -90,13 +90,13 @@ impl<'a> AccountLoginView<'a> { } } -fn show_error(ui: &mut egui::Ui, err: &LoginError) { +fn show_error(ui: &mut egui::Ui, err: &AcquireKeyError) { ui.horizontal(|ui| { let error_label = match err { - LoginError::InvalidKey => { + AcquireKeyError::InvalidKey => { egui::Label::new(RichText::new("Invalid key.").color(ui.visuals().error_fg_color)) } - LoginError::Nip05Failed(e) => { + AcquireKeyError::Nip05Failed(e) => { egui::Label::new(RichText::new(e).color(ui.visuals().error_fg_color)) } }; @@ -126,8 +126,8 @@ fn login_button() -> Button<'static> { .min_size(Vec2::new(0.0, 40.0)) } -fn login_textedit(manager: &mut LoginState) -> TextEdit { - manager.get_login_textedit(|text| { +fn login_textedit(manager: &mut AcquireKeyState) -> TextEdit { + manager.get_acquire_textedit(|text| { egui::TextEdit::singleline(text) .hint_text( RichText::new("Enter your public key (npub, nip05), or private key (nsec) here...") @@ -143,7 +143,7 @@ mod preview { use super::*; pub struct AccountLoginPreview { - manager: LoginState, + manager: AcquireKeyState, } impl View for AccountLoginPreview { @@ -157,7 +157,7 @@ mod preview { fn preview(cfg: PreviewConfig) -> Self::Prev { let _ = cfg; - let manager = LoginState::new(); + let manager = AcquireKeyState::new(); AccountLoginPreview { manager } } } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index 477528f8..9012c411 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -1,17 +1,23 @@ -use egui::{pos2, vec2, Color32, FontId, ImageSource, Pos2, Rect, RichText, Separator, Ui}; +use core::f32; +use std::collections::HashMap; + +use egui::{ + pos2, vec2, Align, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText, Separator, + Ui, Vec2, +}; use nostrdb::Ndb; -use tracing::{error, info}; +use tracing::error; use crate::{ app_style::{get_font_size, NotedeckTextStyle}, - key_parsing::perform_key_retrieval, + login_manager::AcquireKeyState, timeline::{PubkeySource, Timeline, TimelineKind}, ui::anim::ICON_EXPANSION_MULTIPLE, user_account::UserAccount, Damus, }; -use super::anim::AnimationHelper; +use super::{anim::AnimationHelper, padding}; pub enum AddColumnResponse { Timeline(Timeline), @@ -67,13 +73,22 @@ impl AddColumnOption { } pub struct AddColumnView<'a> { + key_state_map: &'a mut HashMap, ndb: &'a Ndb, cur_account: Option<&'a UserAccount>, } impl<'a> AddColumnView<'a> { - pub fn new(ndb: &'a Ndb, cur_account: Option<&'a UserAccount>) -> Self { - Self { ndb, cur_account } + pub fn new( + key_state_map: &'a mut HashMap, + ndb: &'a Ndb, + cur_account: Option<&'a UserAccount>, + ) -> Self { + Self { + key_state_map, + ndb, + cur_account, + } } pub fn ui(&mut self, ui: &mut Ui) -> Option { @@ -105,48 +120,49 @@ impl<'a> AddColumnView<'a> { } fn external_notification_ui(&mut self, ui: &mut Ui) -> Option { - ui.label(RichText::new("External Notification").heading()); - ui.label("Paste the user's npub that you would like to have a notifications column for:"); + padding(16.0, ui, |ui| { + let id = ui.id().with("external_notif"); + let key_state = self.key_state_map.entry(id).or_default(); + + let text_edit = key_state.get_acquire_textedit(|text| { + egui::TextEdit::singleline(text) + .hint_text( + RichText::new("Enter the user's key (npub, hex, nip05) here...") + .text_style(NotedeckTextStyle::Body.text_style()), + ) + .vertical_align(Align::Center) + .desired_width(f32::INFINITY) + .min_size(Vec2::new(0.0, 40.0)) + .margin(Margin::same(12.0)) + }); - let id = ui.id().with("external_notif"); - let mut text = ui.ctx().data_mut(|data| { - let text = data.get_temp_mut_or_insert_with(id, String::new); - text.clone() - }); - ui.text_edit_singleline(&mut text); - ui.ctx().data_mut(|d| d.insert_temp(id, text.clone())); - - if ui.button("Validate").clicked() { - if let Some(payload) = perform_key_retrieval(&text).ready() { - match payload { - Ok(keypair) => { - info!( - "Successfully retrieved external notification keypair {}", - keypair.pubkey - ); - if let Some(resp) = - AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey)) - .take_as_response(self.ndb, self.cur_account) - { - Some(resp) - } else { - error!("Failed to get timeline column"); - None - } - } - Err(_) => { - info!("User did not enter a valid npub or nip05"); - ui.colored_label(Color32::RED, "Please enter a valid npub or nip05"); - None - } - } - } else { + ui.add(text_edit); + + if ui.button("Add").clicked() { + key_state.apply_acquire(); + } + + if key_state.is_awaiting_network() { ui.spinner(); + } + + if let Some(error) = key_state.check_for_error() { + error!("acquire key error: {}", error); + ui.colored_label( + Color32::RED, + "Please enter a valid npub, public hex key or nip05", + ); + } + + if let Some(keypair) = key_state.check_for_successful_login() { + key_state.should_create_new(); + AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey)) + .take_as_response(self.ndb, self.cur_account) + } else { None } - } else { - None - } + }) + .inner } fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { @@ -326,16 +342,24 @@ pub fn render_add_column_routes( route: &AddColumnRoute, ) { let resp = match route { - AddColumnRoute::Base => { - AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).ui(ui) - } - AddColumnRoute::UndecidedNotification => { - AddColumnView::new(&app.ndb, app.accounts.get_selected_account()).notifications_ui(ui) - } - AddColumnRoute::ExternalNotification => { - AddColumnView::new(&app.ndb, app.accounts.get_selected_account()) - .external_notification_ui(ui) - } + AddColumnRoute::Base => AddColumnView::new( + &mut app.view_state.id_state_map, + &app.ndb, + app.accounts.get_selected_account(), + ) + .ui(ui), + AddColumnRoute::UndecidedNotification => AddColumnView::new( + &mut app.view_state.id_state_map, + &app.ndb, + app.accounts.get_selected_account(), + ) + .notifications_ui(ui), + AddColumnRoute::ExternalNotification => AddColumnView::new( + &mut app.view_state.id_state_map, + &app.ndb, + app.accounts.get_selected_account(), + ) + .external_notification_ui(ui), }; if let Some(resp) = resp { @@ -382,7 +406,12 @@ mod preview { impl View for AddColumnPreview { fn ui(&mut self, ui: &mut egui::Ui) { - AddColumnView::new(&self.app.ndb, self.app.accounts.get_selected_account()).ui(ui); + AddColumnView::new( + &mut self.app.view_state.id_state_map, + &self.app.ndb, + self.app.accounts.get_selected_account(), + ) + .ui(ui); } } diff --git a/src/view_state.rs b/src/view_state.rs index 33b94b30..94b032c0 100644 --- a/src/view_state.rs +++ b/src/view_state.rs @@ -1,13 +1,16 @@ -use crate::login_manager::LoginState; +use std::collections::HashMap; + +use crate::login_manager::AcquireKeyState; /// Various state for views #[derive(Default)] pub struct ViewState { - pub login: LoginState, + pub login: AcquireKeyState, + pub id_state_map: HashMap, } impl ViewState { - pub fn login_mut(&mut self) -> &mut LoginState { + pub fn login_mut(&mut self) -> &mut AcquireKeyState { &mut self.login } } From 4c458727a93d28a339f74ab9d8c57d859b01b096 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 13 Nov 2024 15:46:39 -0800 Subject: [PATCH 05/15] fix: save columns on removal Fixes: https://github.com/damus-io/notedeck/issues/432 Signed-off-by: William Casarin --- src/app.rs | 2 -- src/column.rs | 22 +++++++--------------- src/nav.rs | 3 ++- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6f233d57..d886b5d5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -513,8 +513,6 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { } damus.app_rect_handler.try_save_app_size(ctx); - - damus.columns.attempt_perform_deletion_request(); } fn process_event(damus: &mut Damus, _subid: &str, event: &str) { diff --git a/src/column.rs b/src/column.rs index f1d61288..8603620f 100644 --- a/src/column.rs +++ b/src/column.rs @@ -61,7 +61,6 @@ pub struct Columns { /// The selected column for key navigation selected: i32, - should_delete_column_at_index: Option, } static UIDS: AtomicU32 = AtomicU32::new(0); @@ -207,22 +206,15 @@ impl Columns { self.selected += 1; } - pub fn request_deletion_at_index(&mut self, index: usize) { - self.should_delete_column_at_index = Some(index); - } - - pub fn attempt_perform_deletion_request(&mut self) { - if let Some(index) = self.should_delete_column_at_index { - if let Some((key, _)) = self.columns.get_index_mut(index) { - self.timelines.shift_remove(key); - } + pub fn delete_column(&mut self, index: usize) { + if let Some((key, _)) = self.columns.get_index_mut(index) { + self.timelines.shift_remove(key); + } - self.columns.shift_remove_index(index); - self.should_delete_column_at_index = None; + self.columns.shift_remove_index(index); - if self.columns.is_empty() { - self.new_column_picker(); - } + if self.columns.is_empty() { + self.new_column_picker(); } } diff --git a/src/nav.rs b/src/nav.rs index 96b05976..9dcf6ed5 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -205,11 +205,12 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { if let Some(title_response) = nav_response.title_response { match title_response { TitleResponse::RemoveColumn => { - app.columns_mut().request_deletion_at_index(col); let tl = app.columns().find_timeline_for_column_index(col); if let Some(timeline) = tl { unsubscribe_timeline(app.ndb(), timeline); } + app.columns_mut().delete_column(col); + col_changed = true; } } } From 3fb78ae01ebe52c83ccd23509c2d11e9b7674503 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 18 Oct 2024 13:43:57 -0700 Subject: [PATCH 06/15] nav: use id_source instead of show argument Signed-off-by: William Casarin --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/nav.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8edacc81..ff9d6e98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1192,7 +1192,7 @@ dependencies = [ [[package]] name = "egui_nav" version = "0.1.0" -source = "git+https://github.com/damus-io/egui-nav?rev=6ba42de2bae384d10e35c532f3856b81d2e9f645#6ba42de2bae384d10e35c532f3856b81d2e9f645" +source = "git+https://github.com/damus-io/egui-nav?rev=956338a90e09c7cda951d554626483e0cdbc7825#956338a90e09c7cda951d554626483e0cdbc7825" dependencies = [ "egui", "egui_extras", diff --git a/Cargo.toml b/Cargo.toml index e0d60cb6..350bf89e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] } ehttp = "0.2.0" egui_tabs = { git = "https://github.com/damus-io/egui-tabs", branch = "egui-0.28" } -egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "6ba42de2bae384d10e35c532f3856b81d2e9f645" } +egui_nav = { git = "https://github.com/damus-io/egui-nav", rev = "956338a90e09c7cda951d554626483e0cdbc7825" } egui_virtual_list = { git = "https://github.com/jb55/hello_egui", branch = "egui-0.28", package = "egui_virtual_list" } reqwest = { version = "0.12.4", default-features = false, features = [ "rustls-tls-native-roots" ] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } diff --git a/src/nav.rs b/src/nav.rs index 9dcf6ed5..c4ebfc7d 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -42,8 +42,9 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { let nav_response = Nav::new(routes) .navigating(app.columns_mut().column_mut(col).router_mut().navigating) .returning(app.columns_mut().column_mut(col).router_mut().returning) + .id_source(egui::Id::new(col_id)) .title(48.0, title_bar) - .show_mut(col_id, ui, |ui, nav| { + .show_mut(ui, |ui, nav| { let column = app.columns.column_mut(col); match &nav.top().route { Route::Timeline(tlr) => render_timeline_route( From 845f745dca985125cbceb66546f28827b5b00943 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Thu, 14 Nov 2024 10:36:16 -0800 Subject: [PATCH 07/15] remove NewTimelineSub New timelines should be handled in the standard timeline codepaths Signed-off-by: William Casarin --- src/app.rs | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index d886b5d5..4be1559a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -43,7 +43,6 @@ use tracing::{debug, error, info, trace, warn}; pub enum DamusState { Initializing, Initialized, - NewTimelineSub(TimelineId), } /// We derive Deserialize/Serialize so we can persist app state on shutdown. @@ -478,33 +477,6 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { .expect("home subscription failed"); } - DamusState::NewTimelineSub(new_timeline_id) => { - info!("adding new timeline {}", new_timeline_id); - setup_new_nostrdb_sub( - &damus.ndb, - &mut damus.note_cache, - &mut damus.columns, - new_timeline_id, - ) - .expect("new timeline subscription failed"); - - if let Some(filter) = { - let timeline = damus - .columns - .find_timeline(new_timeline_id) - .expect("timeline"); - match &timeline.filter { - FilterState::Ready(filters) => Some(filters.clone()), - _ => None, - } - } { - let subid = Uuid::new_v4().to_string(); - damus.pool.subscribe(subid, filter); - - damus.state = DamusState::Initialized; - } - } - DamusState::Initialized => (), }; @@ -819,10 +791,6 @@ impl Damus { } } - pub fn subscribe_new_timeline(&mut self, timeline_id: TimelineId) { - self.state = DamusState::NewTimelineSub(timeline_id); - } - pub fn mock>(data_path: P) -> Self { let mut columns = Columns::new(); let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); From acadc4a9d993452991f9be6a77d4883b96d1354d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Thu, 14 Nov 2024 11:43:35 -0800 Subject: [PATCH 08/15] enostr: add PoolEventBuf to simplify lifetimes in some situations Signed-off-by: William Casarin --- enostr/src/relay/pool.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/enostr/src/relay/pool.rs b/enostr/src/relay/pool.rs index de4e1efc..3856d8d1 100644 --- a/enostr/src/relay/pool.rs +++ b/enostr/src/relay/pool.rs @@ -18,6 +18,20 @@ pub struct PoolEvent<'a> { pub event: ewebsock::WsEvent, } +impl<'a> PoolEvent<'a> { + pub fn into_owned(self) -> PoolEventBuf { + PoolEventBuf { + relay: self.relay.to_owned(), + event: self.event + } + } +} + +pub struct PoolEventBuf { + pub relay: String, + pub event: ewebsock::WsEvent, +} + pub struct PoolRelay { pub relay: Relay, pub last_ping: Instant, From 01bedac71043b1e3f661bb60568dfaac3d342c31 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Thu, 14 Nov 2024 14:44:33 -0800 Subject: [PATCH 09/15] Fix filter states when adding columns This fixes various issues with filter states when adding columns. We now maintain multiple states per relay so that we don't lose track of anything. Fixes: https://github.com/damus-io/notedeck/issues/431 Fixes: https://github.com/damus-io/notedeck/issues/359 Signed-off-by: William Casarin --- enostr/src/relay/pool.rs | 2 +- src/app.rs | 333 ++++++--------------------------------- src/filter.rs | 69 ++++++++ src/profile.rs | 3 +- src/subscriptions.rs | 5 + src/timeline/mod.rs | 328 ++++++++++++++++++++++++++++++++++++-- src/ui/add_column.rs | 12 +- 7 files changed, 451 insertions(+), 301 deletions(-) diff --git a/enostr/src/relay/pool.rs b/enostr/src/relay/pool.rs index 3856d8d1..ed8d37aa 100644 --- a/enostr/src/relay/pool.rs +++ b/enostr/src/relay/pool.rs @@ -22,7 +22,7 @@ impl<'a> PoolEvent<'a> { pub fn into_owned(self) -> PoolEventBuf { PoolEventBuf { relay: self.relay.to_owned(), - event: self.event + event: self.event, } } } diff --git a/src/app.rs b/src/app.rs index 4be1559a..b99f517c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,20 +6,18 @@ use crate::{ args::Args, column::Columns, draft::Drafts, - error::{Error, FilterError}, - filter::{self, FilterState}, + filter::FilterState, frame_history::FrameHistory, imgcache::ImageCache, nav, - note::NoteRef, - notecache::{CachedNote, NoteCache}, + notecache::NoteCache, notes_holder::NotesHolderStorage, profile::Profile, storage::{self, DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, support::Support, thread::Thread, - timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, + timeline::{self, Timeline, TimelineKind}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, @@ -32,12 +30,12 @@ use uuid::Uuid; use egui::{Context, Frame, Style}; use egui_extras::{Size, StripBuilder}; -use nostrdb::{Config, Filter, Ndb, Note, Transaction}; +use nostrdb::{Config, Filter, Ndb, Transaction}; use std::collections::HashMap; use std::path::Path; use std::time::Duration; -use tracing::{debug, error, info, trace, warn}; +use tracing::{error, info, trace, warn}; #[derive(Debug, Eq, PartialEq, Clone)] pub enum DamusState { @@ -98,98 +96,6 @@ fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { } } -fn send_initial_timeline_filter( - ndb: &Ndb, - can_since_optimize: bool, - subs: &mut Subscriptions, - pool: &mut RelayPool, - timeline: &mut Timeline, - to: &str, -) { - let filter_state = timeline.filter.clone(); - - match filter_state { - FilterState::Broken(err) => { - error!( - "FetchingRemote state in broken state when sending initial timeline filter? {err}" - ); - } - - FilterState::FetchingRemote(_unisub) => { - error!("FetchingRemote state when sending initial timeline filter?"); - } - - FilterState::GotRemote(_sub) => { - error!("GotRemote state when sending initial timeline filter?"); - } - - FilterState::Ready(filter) => { - let filter = filter.to_owned(); - let new_filters = filter.into_iter().map(|f| { - // limit the size of remote filters - let default_limit = filter::default_remote_limit(); - let mut lim = f.limit().unwrap_or(default_limit); - let mut filter = f; - if lim > default_limit { - lim = default_limit; - filter = filter.limit_mut(lim); - } - - let notes = timeline.notes(ViewFilter::NotesAndReplies); - - // Should we since optimize? Not always. For example - // if we only have a few 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 - if can_since_optimize && filter::should_since_optimize(lim, notes.len()) { - filter = filter::since_optimize_filter(filter, notes); - } else { - warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", filter); - } - - filter - }).collect(); - - //let sub_id = damus.gen_subid(&SubKind::Initial); - let sub_id = Uuid::new_v4().to_string(); - subs.subs.insert(sub_id.clone(), SubKind::Initial); - - let cmd = ClientMessage::req(sub_id, new_filters); - pool.send_to(&cmd, to); - } - - // we need some data first - FilterState::NeedsRemote(filter) => { - let sub_kind = SubKind::FetchingContactList(timeline.id); - //let sub_id = damus.gen_subid(&sub_kind); - let sub_id = Uuid::new_v4().to_string(); - let local_sub = ndb.subscribe(&filter).expect("sub"); - - timeline.filter = FilterState::fetching_remote(sub_id.clone(), local_sub); - - subs.subs.insert(sub_id.clone(), sub_kind); - - pool.subscribe(sub_id, filter.to_owned()); - } - } -} - -fn send_initial_filters(damus: &mut Damus, relay_url: &str) { - info!("Sending initial filters to {}", relay_url); - for timeline in damus.columns.timelines_mut() { - send_initial_timeline_filter( - &damus.ndb, - damus.since_optimize, - &mut damus.subscriptions, - &mut damus.pool, - timeline, - relay_url, - ); - } -} - fn handle_key_events(input: &egui::InputState, _pixels_per_point: f32, columns: &mut Columns) { for event in &input.raw.events { if let egui::Event::Key { @@ -225,17 +131,31 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { }; damus.pool.keepalive_ping(wakeup); - // pool stuff - while let Some(ev) = damus.pool.try_recv() { - let relay = ev.relay.to_owned(); + // NOTE: we don't use the while let loop due to borrow issues + #[allow(clippy::while_let_loop)] + loop { + let ev = if let Some(ev) = damus.pool.try_recv() { + ev.into_owned() + } else { + break; + }; match (&ev.event).into() { - RelayEvent::Opened => send_initial_filters(damus, &relay), + RelayEvent::Opened => { + timeline::send_initial_timeline_filters( + &damus.ndb, + damus.since_optimize, + &mut damus.columns, + &mut damus.subscriptions, + &mut damus.pool, + &ev.relay, + ); + } // TODO: handle reconnects - RelayEvent::Closed => warn!("{} connection closed", &relay), - RelayEvent::Error(e) => error!("{}: {}", &relay, e), + RelayEvent::Closed => warn!("{} connection closed", &ev.relay), + RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e), RelayEvent::Other(msg) => trace!("other event {:?}", &msg), - RelayEvent::Message(msg) => process_message(damus, &relay, &msg), + RelayEvent::Message(msg) => process_message(damus, &ev.relay, &msg), } } @@ -243,9 +163,11 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { for timeline_ind in 0..n_timelines { let is_ready = { let timeline = &mut damus.columns.timelines[timeline_ind]; - matches!( - is_timeline_ready(&damus.ndb, &mut damus.pool, &mut damus.note_cache, timeline), - Ok(true) + timeline::is_timeline_ready( + &damus.ndb, + &mut damus.pool, + &mut damus.note_cache, + timeline, ) }; @@ -285,183 +207,11 @@ fn unknown_id_send(damus: &mut Damus) { damus.pool.send(&msg); } -/// Check our timeline filter and see if we have any filter data ready. -/// Our timelines may require additional data before it is functional. For -/// example, when we have to fetch a contact list before we do the actual -/// following list query. -fn is_timeline_ready( - ndb: &Ndb, - pool: &mut RelayPool, - note_cache: &mut NoteCache, - timeline: &mut Timeline, -) -> Result { - let sub = match &timeline.filter { - FilterState::GotRemote(sub) => *sub, - FilterState::Ready(_f) => return Ok(true), - _ => return Ok(false), - }; - - // We got at least one eose for our filter request. Let's see - // if nostrdb is done processing it yet. - let res = ndb.poll_for_notes(sub, 1); - if res.is_empty() { - debug!( - "check_timeline_filter_state: no notes found (yet?) for timeline {:?}", - timeline - ); - return Ok(false); - } - - info!("notes found for contact timeline after GotRemote!"); - - let note_key = res[0]; - - let filter = { - let txn = Transaction::new(ndb).expect("txn"); - let note = ndb.get_note_by_key(&txn, note_key).expect("note"); - filter::filter_from_tags(¬e).map(|f| f.into_follow_filter()) - }; - - // TODO: into_follow_filter is hardcoded to contact lists, let's generalize - match filter { - Err(Error::Filter(e)) => { - error!("got broken when building filter {e}"); - timeline.filter = FilterState::broken(e); - } - Err(err) => { - error!("got broken when building filter {err}"); - timeline.filter = FilterState::broken(FilterError::EmptyContactList); - return Err(err); - } - Ok(filter) => { - // we just switched to the ready state, we should send initial - // queries and setup the local subscription - info!("Found contact list! Setting up local and remote contact list query"); - setup_initial_timeline(ndb, timeline, note_cache, &filter).expect("setup init"); - timeline.filter = FilterState::ready(filter.clone()); - - //let ck = &timeline.kind; - //let subid = damus.gen_subid(&SubKind::Column(ck.clone())); - let subid = Uuid::new_v4().to_string(); - pool.subscribe(subid, filter) - } - } - - Ok(true) -} - #[cfg(feature = "profiling")] fn setup_profiling() { puffin::set_scopes_on(true); // tell puffin to collect data } -fn setup_initial_timeline( - ndb: &Ndb, - timeline: &mut Timeline, - note_cache: &mut NoteCache, - filters: &[Filter], -) -> Result<()> { - timeline.subscription = Some(ndb.subscribe(filters)?); - let txn = Transaction::new(ndb)?; - debug!( - "querying nostrdb sub {:?} {:?}", - timeline.subscription, timeline.filter - ); - let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; - let notes = ndb - .query(&txn, filters, lim)? - .into_iter() - .map(NoteRef::from_query_result) - .collect(); - - copy_notes_into_timeline(timeline, &txn, ndb, note_cache, notes); - - Ok(()) -} - -pub fn copy_notes_into_timeline( - timeline: &mut Timeline, - txn: &Transaction, - ndb: &Ndb, - note_cache: &mut NoteCache, - notes: Vec, -) { - let filters = { - let views = &timeline.views; - let filters: Vec bool> = - views.iter().map(|v| v.filter.filter()).collect(); - filters - }; - - for note_ref in notes { - for (view, filter) in filters.iter().enumerate() { - if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) { - if filter( - note_cache.cached_note_or_insert_mut(note_ref.key, ¬e), - ¬e, - ) { - timeline.views[view].notes.push(note_ref) - } - } - } - } -} - -fn setup_initial_nostrdb_subs( - ndb: &Ndb, - note_cache: &mut NoteCache, - columns: &mut Columns, -) -> Result<()> { - for timeline in columns.timelines_mut() { - setup_nostrdb_sub(ndb, note_cache, timeline)? - } - - Ok(()) -} - -fn setup_nostrdb_sub(ndb: &Ndb, note_cache: &mut NoteCache, timeline: &mut Timeline) -> Result<()> { - match &timeline.filter { - FilterState::Ready(filters) => { - { setup_initial_timeline(ndb, timeline, note_cache, &filters.clone()) }? - } - - FilterState::Broken(err) => { - error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") - } - FilterState::FetchingRemote(_) => { - error!("FetchingRemote state in setup_initial_nostr_subs") - } - FilterState::GotRemote(_) => { - error!("GotRemote state in setup_initial_nostr_subs") - } - FilterState::NeedsRemote(_filters) => { - // can't do anything yet, we defer to first connect to send - // remote filters - } - } - - Ok(()) -} - -fn setup_new_nostrdb_sub( - ndb: &Ndb, - note_cache: &mut NoteCache, - columns: &mut Columns, - new_timeline_id: TimelineId, -) -> Result<()> { - if let Some(timeline) = columns.find_timeline_mut(new_timeline_id) { - info!("Setting up timeline sub for {}", timeline.id); - if let FilterState::Ready(filters) = &timeline.filter { - for filter in filters { - info!("Setting up filter {:?}", filter.json()); - } - } - setup_nostrdb_sub(ndb, note_cache, timeline)? - } - - Ok(()) -} - fn update_damus(damus: &mut Damus, ctx: &egui::Context) { match damus.state { DamusState::Initializing => { @@ -473,8 +223,12 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); - setup_initial_nostrdb_subs(&damus.ndb, &mut damus.note_cache, &mut damus.columns) - .expect("home subscription failed"); + timeline::setup_initial_nostrdb_subs( + &damus.ndb, + &mut damus.note_cache, + &mut damus.columns, + ) + .expect("home subscription failed"); } DamusState::Initialized => (), @@ -545,10 +299,12 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { return Ok(()); }; + let filter_state = timeline.filter.get(relay_url); + // If this request was fetching a contact list, our filter // state should be "FetchingRemote". We look at the local // subscription for that filter state and get the subscription id - let local_sub = if let FilterState::FetchingRemote(unisub) = &timeline.filter { + let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state { unisub.local } else { // TODO: we could have multiple contact list results, we need @@ -560,10 +316,17 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { return Ok(()); }; + info!( + "got contact list from {}, updating filter_state to got_remote", + relay_url + ); + // We take the subscription id and pass it to the new state of // "GotRemote". This will let future frames know that it can try // to look for the contact list in nostrdb. - timeline.filter = FilterState::got_remote(local_sub); + timeline + .filter + .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub)); } } diff --git a/src/filter.rs b/src/filter.rs index 85a08eed..d0204b64 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -2,6 +2,7 @@ use crate::error::{Error, FilterError}; use crate::note::NoteRef; use crate::Result; use nostrdb::{Filter, FilterBuilder, Note, Subscription}; +use std::collections::HashMap; use tracing::{debug, warn}; /// A unified subscription has a local and remote component. The remote subid @@ -12,6 +13,74 @@ pub struct UnifiedSubscription { pub remote: String, } +/// Each relay can have a different filter state. For example, some +/// relays may have the contact list, some may not. Let's capture all of +/// these states so that some relays don't stop the states of other +/// relays. +#[derive(Debug)] +pub struct FilterStates { + pub initial_state: FilterState, + pub states: HashMap, +} + +impl FilterStates { + pub fn get(&mut self, relay: &str) -> &FilterState { + // if our initial state is ready, then just use that + if let FilterState::Ready(_) = self.initial_state { + &self.initial_state + } else { + // otherwise we look at relay states + if !self.states.contains_key(relay) { + self.states + .insert(relay.to_string(), self.initial_state.clone()); + } + self.states.get(relay).unwrap() + } + } + + pub fn get_any_gotremote(&self) -> Option<(&str, Subscription)> { + for (k, v) in self.states.iter() { + if let FilterState::GotRemote(sub) = v { + return Some((k, *sub)); + } + } + + None + } + + pub fn get_any_ready(&self) -> Option<&Vec> { + if let FilterState::Ready(fs) = &self.initial_state { + Some(fs) + } else { + for (_k, v) in self.states.iter() { + if let FilterState::Ready(ref fs) = v { + return Some(fs); + } + } + + None + } + } + + pub fn new(initial_state: FilterState) -> Self { + Self { + initial_state, + states: HashMap::new(), + } + } + + pub fn set_relay_state(&mut self, relay: String, state: FilterState) { + if self.states.contains_key(&relay) { + let current_state = self.states.get(&relay).unwrap(); + warn!( + "set_relay_state: we already have the {:?} state set for {}. overriding with {:?}", + current_state, &relay, state + ); + } + self.states.insert(relay, state); + } +} + /// We may need to fetch some data from relays before our filter is ready. /// [`FilterState`] tracks this. #[derive(Debug, Clone)] diff --git a/src/profile.rs b/src/profile.rs index 662e4719..3bfb3a7c 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -2,13 +2,12 @@ use enostr::{Filter, Pubkey}; use nostrdb::{FilterBuilder, Ndb, ProfileRecord, Transaction}; use crate::{ - app::copy_notes_into_timeline, filter::{self, FilterState}, multi_subscriber::MultiSubscriber, note::NoteRef, notecache::NoteCache, notes_holder::NotesHolder, - timeline::{PubkeySource, Timeline, TimelineKind}, + timeline::{copy_notes_into_timeline, PubkeySource, Timeline, TimelineKind}, }; pub enum DisplayName<'a> { diff --git a/src/subscriptions.rs b/src/subscriptions.rs index d006bf42..5396c17f 100644 --- a/src/subscriptions.rs +++ b/src/subscriptions.rs @@ -1,5 +1,6 @@ use crate::timeline::{TimelineId, TimelineKind}; use std::collections::HashMap; +use uuid::Uuid; #[derive(Debug, Clone)] pub enum SubKind { @@ -25,3 +26,7 @@ pub enum SubKind { pub struct Subscriptions { pub subs: HashMap, } + +pub fn new_sub_id() -> String { + Uuid::new_v4().to_string() +} diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index d92e0f97..cf1e864e 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -1,20 +1,26 @@ -use crate::error::Error; -use crate::note::NoteRef; -use crate::notecache::{CachedNote, NoteCache}; -use crate::unknowns::UnknownIds; -use crate::Result; -use crate::{filter, filter::FilterState}; +use crate::{ + column::Columns, + error::{Error, FilterError}, + filter::{self, FilterState, FilterStates}, + note::NoteRef, + notecache::{CachedNote, NoteCache}, + subscriptions::{self, SubKind, Subscriptions}, + unknowns::UnknownIds, + Result, +}; + use std::fmt; use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; -use nostrdb::{Ndb, Note, Subscription, Transaction}; +use enostr::{Relay, RelayPool}; +use nostrdb::{Filter, Ndb, Note, Subscription, Transaction}; use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::hash::Hash; use std::rc::Rc; -use tracing::{debug, error}; +use tracing::{debug, error, info, warn}; pub mod kind; pub mod route; @@ -170,7 +176,7 @@ pub struct Timeline { pub kind: TimelineKind, // We may not have the filter loaded yet, so let's make it an option so // that codepaths have to explicitly handle it - pub filter: FilterState, + pub filter: FilterStates, pub views: Vec, pub selected_view: i32, @@ -209,10 +215,11 @@ impl Timeline { Timeline::make_view_id(self.id, self.selected_view) } - pub fn new(kind: TimelineKind, filter: FilterState) -> Self { + pub fn new(kind: TimelineKind, filter_state: FilterState) -> Self { // global unique id for all new timelines static UIDS: AtomicU32 = AtomicU32::new(0); + let filter = FilterStates::new(filter_state); let subscription: Option = None; let notes = TimelineTab::new(ViewFilter::Notes); let replies = TimelineTab::new(ViewFilter::NotesAndReplies); @@ -370,3 +377,304 @@ pub fn merge_sorted_vecs(vec1: &[T], vec2: &[T]) -> (Vec, Merg (merged, result.unwrap_or(MergeKind::FrontInsert)) } + +/// When adding a new timeline, we may have a situation where the +/// FilterState is NeedsRemote. This can happen if we don't yet have the +/// contact list, etc. For these situations, we query all of the relays +/// with the same sub_id. We keep track of this sub_id and update the +/// filter with the latest version of the returned filter (ie contact +/// list) when they arrive. +/// +/// We do this by maintaining this sub_id in the filter state, even when +/// in the ready state. See: [`FilterReady`] +pub fn setup_new_timeline( + timeline: &mut Timeline, + ndb: &Ndb, + subs: &mut Subscriptions, + pool: &mut RelayPool, + note_cache: &mut NoteCache, + since_optimize: bool, +) { + // if we're ready, setup local subs + if is_timeline_ready(ndb, pool, note_cache, timeline) { + if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline) { + error!("setup_new_timeline: {err}"); + } + } + + for relay in &mut pool.relays { + send_initial_timeline_filter(ndb, since_optimize, subs, &mut relay.relay, timeline); + } +} + +/// Send initial filters for a specific relay. This typically gets called +/// when we first connect to a new relay for the first time. For +/// situations where you are adding a new timeline, use +/// setup_new_timeline. +pub fn send_initial_timeline_filters( + ndb: &Ndb, + since_optimize: bool, + columns: &mut Columns, + subs: &mut Subscriptions, + pool: &mut RelayPool, + relay_id: &str, +) -> Option<()> { + info!("Sending initial filters to {}", relay_id); + let relay = &mut pool + .relays + .iter_mut() + .find(|r| r.relay.url == relay_id)? + .relay; + + for timeline in columns.timelines_mut() { + send_initial_timeline_filter(ndb, since_optimize, subs, relay, timeline); + } + + Some(()) +} + +pub fn send_initial_timeline_filter( + ndb: &Ndb, + can_since_optimize: bool, + subs: &mut Subscriptions, + relay: &mut Relay, + timeline: &mut Timeline, +) { + let filter_state = timeline.filter.get(&relay.url); + + match filter_state { + FilterState::Broken(err) => { + error!( + "FetchingRemote state in broken state when sending initial timeline filter? {err}" + ); + } + + FilterState::FetchingRemote(_unisub) => { + error!("FetchingRemote state when sending initial timeline filter?"); + } + + FilterState::GotRemote(_sub) => { + error!("GotRemote state when sending initial timeline filter?"); + } + + FilterState::Ready(filter) => { + let filter = filter.to_owned(); + let new_filters = filter.into_iter().map(|f| { + // limit the size of remote filters + let default_limit = filter::default_remote_limit(); + let mut lim = f.limit().unwrap_or(default_limit); + let mut filter = f; + if lim > default_limit { + lim = default_limit; + filter = filter.limit_mut(lim); + } + + let notes = timeline.notes(ViewFilter::NotesAndReplies); + + // Should we since optimize? Not always. For example + // if we only have a few 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 + if can_since_optimize && filter::should_since_optimize(lim, notes.len()) { + filter = filter::since_optimize_filter(filter, notes); + } else { + warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", filter); + } + + filter + }).collect(); + + //let sub_id = damus.gen_subid(&SubKind::Initial); + let sub_id = subscriptions::new_sub_id(); + subs.subs.insert(sub_id.clone(), SubKind::Initial); + + relay.subscribe(sub_id, new_filters); + } + + // we need some data first + FilterState::NeedsRemote(filter) => { + fetch_contact_list(filter.to_owned(), ndb, subs, relay, timeline) + } + } +} + +fn fetch_contact_list( + filter: Vec, + ndb: &Ndb, + subs: &mut Subscriptions, + relay: &mut Relay, + timeline: &mut Timeline, +) { + let sub_kind = SubKind::FetchingContactList(timeline.id); + let sub_id = subscriptions::new_sub_id(); + let local_sub = ndb.subscribe(&filter).expect("sub"); + + timeline.filter.set_relay_state( + relay.url.clone(), + FilterState::fetching_remote(sub_id.clone(), local_sub), + ); + + subs.subs.insert(sub_id.clone(), sub_kind); + + info!("fetching contact list from {}", &relay.url); + relay.subscribe(sub_id, filter); +} + +fn setup_initial_timeline( + ndb: &Ndb, + timeline: &mut Timeline, + note_cache: &mut NoteCache, + filters: &[Filter], +) -> Result<()> { + timeline.subscription = Some(ndb.subscribe(filters)?); + let txn = Transaction::new(ndb)?; + debug!( + "querying nostrdb sub {:?} {:?}", + timeline.subscription, timeline.filter + ); + let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; + let notes = ndb + .query(&txn, filters, lim)? + .into_iter() + .map(NoteRef::from_query_result) + .collect(); + + copy_notes_into_timeline(timeline, &txn, ndb, note_cache, notes); + + Ok(()) +} + +pub fn copy_notes_into_timeline( + timeline: &mut Timeline, + txn: &Transaction, + ndb: &Ndb, + note_cache: &mut NoteCache, + notes: Vec, +) { + let filters = { + let views = &timeline.views; + let filters: Vec bool> = + views.iter().map(|v| v.filter.filter()).collect(); + filters + }; + + for note_ref in notes { + for (view, filter) in filters.iter().enumerate() { + if let Ok(note) = ndb.get_note_by_key(txn, note_ref.key) { + if filter( + note_cache.cached_note_or_insert_mut(note_ref.key, ¬e), + ¬e, + ) { + timeline.views[view].notes.push(note_ref) + } + } + } + } +} + +pub fn setup_initial_nostrdb_subs( + ndb: &Ndb, + note_cache: &mut NoteCache, + columns: &mut Columns, +) -> Result<()> { + for timeline in columns.timelines_mut() { + setup_timeline_nostrdb_sub(ndb, note_cache, timeline)?; + } + + Ok(()) +} + +fn setup_timeline_nostrdb_sub( + ndb: &Ndb, + note_cache: &mut NoteCache, + timeline: &mut Timeline, +) -> Result<()> { + let filter_state = timeline + .filter + .get_any_ready() + .ok_or(Error::empty_contact_list())? + .to_owned(); + + setup_initial_timeline(ndb, timeline, note_cache, &filter_state)?; + + Ok(()) +} + +/// Check our timeline filter and see if we have any filter data ready. +/// Our timelines may require additional data before it is functional. For +/// example, when we have to fetch a contact list before we do the actual +/// following list query. +pub fn is_timeline_ready( + ndb: &Ndb, + pool: &mut RelayPool, + note_cache: &mut NoteCache, + timeline: &mut Timeline, +) -> bool { + // TODO: we should debounce the filter states a bit to make sure we have + // seen all of the different contact lists from each relay + if let Some(_f) = timeline.filter.get_any_ready() { + return true; + } + + let (relay_id, sub) = if let Some((relay_id, sub)) = timeline.filter.get_any_gotremote() { + (relay_id.to_string(), sub) + } else { + return false; + }; + + // We got at least one eose for our filter request. Let's see + // if nostrdb is done processing it yet. + let res = ndb.poll_for_notes(sub, 1); + if res.is_empty() { + debug!( + "check_timeline_filter_state: no notes found (yet?) for timeline {:?}", + timeline + ); + return false; + } + + info!("notes found for contact timeline after GotRemote!"); + + let note_key = res[0]; + + let filter = { + let txn = Transaction::new(ndb).expect("txn"); + let note = ndb.get_note_by_key(&txn, note_key).expect("note"); + filter::filter_from_tags(¬e).map(|f| f.into_follow_filter()) + }; + + // TODO: into_follow_filter is hardcoded to contact lists, let's generalize + match filter { + Err(Error::Filter(e)) => { + error!("got broken when building filter {e}"); + timeline + .filter + .set_relay_state(relay_id, FilterState::broken(e)); + false + } + Err(err) => { + error!("got broken when building filter {err}"); + timeline + .filter + .set_relay_state(relay_id, FilterState::broken(FilterError::EmptyContactList)); + false + } + Ok(filter) => { + // we just switched to the ready state, we should send initial + // queries and setup the local subscription + info!("Found contact list! Setting up local and remote contact list query"); + setup_initial_timeline(ndb, timeline, note_cache, &filter).expect("setup init"); + timeline + .filter + .set_relay_state(relay_id, FilterState::ready(filter.clone())); + + //let ck = &timeline.kind; + //let subid = damus.gen_subid(&SubKind::Column(ck.clone())); + let subid = subscriptions::new_sub_id(); + pool.subscribe(subid, filter); + true + } + } +} diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index 308186a2..2ca30bae 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -361,10 +361,16 @@ pub fn render_add_column_routes( if let Some(resp) = resp { match resp { - AddColumnResponse::Timeline(timeline) => { - let id = timeline.id; + AddColumnResponse::Timeline(mut timeline) => { + crate::timeline::setup_new_timeline( + &mut timeline, + &app.ndb, + &mut app.subscriptions, + &mut app.pool, + &mut app.note_cache, + app.since_optimize, + ); app.columns_mut().add_timeline_to_column(col, timeline); - app.subscribe_new_timeline(id); } AddColumnResponse::UndecidedNotification => { app.columns_mut().column_mut(col).router_mut().route_to( From 4d124c05fa6a72bece1cf8560a3a767f139d1a3f Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 15 Nov 2024 10:08:46 -0800 Subject: [PATCH 10/15] remove unnecessary crash Signed-off-by: William Casarin --- src/app.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index b99f517c..6c9f4620 100644 --- a/src/app.rs +++ b/src/app.rs @@ -223,12 +223,13 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); - timeline::setup_initial_nostrdb_subs( + if let Err(err) = timeline::setup_initial_nostrdb_subs( &damus.ndb, &mut damus.note_cache, &mut damus.columns, - ) - .expect("home subscription failed"); + ) { + warn!("update_damus init: {err}"); + } } DamusState::Initialized => (), From ebfa9e4450ec799e9e2c28b20af551805635bf98 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 15 Nov 2024 10:11:04 -0800 Subject: [PATCH 11/15] fix log message for relay states Signed-off-by: William Casarin --- src/filter.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/filter.rs b/src/filter.rs index d0204b64..1b470a2a 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -72,9 +72,9 @@ impl FilterStates { pub fn set_relay_state(&mut self, relay: String, state: FilterState) { if self.states.contains_key(&relay) { let current_state = self.states.get(&relay).unwrap(); - warn!( - "set_relay_state: we already have the {:?} state set for {}. overriding with {:?}", - current_state, &relay, state + debug!( + "set_relay_state: {:?} -> {:?} on {}", + current_state, state, &relay, ); } self.states.insert(relay, state); From 8043d86bf252f53c97470eeb1f641aaa7d083d27 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 15 Nov 2024 11:56:34 -0800 Subject: [PATCH 12/15] arg: fix broken dbpath argument, add test I broke dbpath, lets fix that and add a test so it doesn't happen again Signed-off-by: William Casarin --- src/app.rs | 9 +++++---- src/args.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6c9f4620..126386ed 100644 --- a/src/app.rs +++ b/src/app.rs @@ -392,10 +392,11 @@ impl Damus { .datapath .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string()); let path = DataPath::new(&data_path); - let dbpath_ = path.path(DataPathType::Db); - let dbpath = dbpath_.to_str().unwrap(); + let dbpath_str = parsed_args + .dbpath + .unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string()); - let _ = std::fs::create_dir_all(dbpath); + let _ = std::fs::create_dir_all(&dbpath_str); let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir()); let _ = std::fs::create_dir_all(imgcache_dir.clone()); @@ -453,7 +454,7 @@ impl Damus { .get_selected_account() .as_ref() .map(|a| a.pubkey.bytes()); - let ndb = Ndb::new(dbpath, &config).expect("ndb"); + let ndb = Ndb::new(&dbpath_str, &config).expect("ndb"); let mut columns = if parsed_args.columns.is_empty() { if let Some(serializable_columns) = storage::load_columns(&path) { diff --git a/src/args.rs b/src/args.rs index 7d8e3f05..9acc15e4 100644 --- a/src/args.rs +++ b/src/args.rs @@ -265,6 +265,32 @@ mod tests { std::fs::remove_dir_all(path); } + /// Ensure dbpath actually sets the dbpath correctly. + #[tokio::test] + async fn test_dbpath() { + let datapath = create_tmp_dir(); + let dbpath = create_tmp_dir(); + let args = vec![ + "--datapath", + &datapath.to_str().unwrap(), + "--dbpath", + &dbpath.to_str().unwrap(), + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + let ctx = egui::Context::default(); + let app = Damus::new(&ctx, &datapath, args); + + assert!(Path::new(&dbpath.join("data.mdb")).exists()); + assert!(Path::new(&dbpath.join("lock.mdb")).exists()); + assert!(!Path::new(&datapath.join("db")).exists()); + + rmrf(datapath); + rmrf(dbpath); + } + #[tokio::test] async fn test_column_args() { let tmpdir = create_tmp_dir(); From 33b2fa263eb79b7c4a57b440cac40cf204fef74e Mon Sep 17 00:00:00 2001 From: William Casarin Date: Fri, 15 Nov 2024 12:25:34 -0800 Subject: [PATCH 13/15] fix issue where columns fail to load on first failure This fixes an issue where if one of the columns fail to load, all other columns fail to load. This is because we are too aggressive with the early exit on failure. To reproduce: $ notedeck --dbpath what With existing columns in an existing data path. Signed-off-by: William Casarin --- src/timeline/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index cf1e864e..ca7a76b0 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -580,7 +580,9 @@ pub fn setup_initial_nostrdb_subs( columns: &mut Columns, ) -> Result<()> { for timeline in columns.timelines_mut() { - setup_timeline_nostrdb_sub(ndb, note_cache, timeline)?; + if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline) { + error!("setup_initial_nostrdb_subs: {err}"); + } } Ok(()) From a678e647a479d556fe8809d09a4abb8c85e3f0cc Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 17 Nov 2024 16:44:52 -0800 Subject: [PATCH 14/15] hide media on universe view Also fixes textmode Fixes: https://github.com/damus-io/notedeck/issues/443 --- src/timeline/route.rs | 41 ++++++++++++++++++++++++++++++++++------- src/ui/note/contents.rs | 3 ++- src/ui/note/mod.rs | 5 +++++ src/ui/note/options.rs | 19 +++++++++++++++++++ src/ui/profile/mod.rs | 10 ++++++++-- src/ui/thread.rs | 8 +++++++- src/ui/timeline.rs | 23 +++++++++++------------ 7 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/timeline/route.rs b/src/timeline/route.rs index 059989f0..1769f87f 100644 --- a/src/timeline/route.rs +++ b/src/timeline/route.rs @@ -7,12 +7,12 @@ use crate::{ notes_holder::NotesHolderStorage, profile::Profile, thread::Thread, - timeline::TimelineId, + timeline::{TimelineId, TimelineKind}, ui::{ self, note::{ post::{PostAction, PostResponse}, - QuoteRepostView, + NoteOptions, QuoteRepostView, }, profile::ProfileView, }, @@ -57,9 +57,28 @@ pub fn render_timeline_route( ) -> Option { match route { TimelineRoute::Timeline(timeline_id) => { - let timeline_response = - ui::TimelineView::new(timeline_id, columns, ndb, note_cache, img_cache, textmode) - .ui(ui); + let note_options = { + let is_universe = if let Some(timeline) = columns.find_timeline(timeline_id) { + timeline.kind == TimelineKind::Universe + } else { + false + }; + + let mut options = NoteOptions::new(is_universe); + options.set_textmode(textmode); + options + }; + + let timeline_response = ui::TimelineView::new( + timeline_id, + columns, + ndb, + note_cache, + img_cache, + note_options, + ) + .ui(ui); + if let Some(bar_action) = timeline_response.bar_action { let txn = Transaction::new(ndb).expect("txn"); let mut cur_column = columns.columns_mut(); @@ -168,8 +187,16 @@ pub fn render_profile_route( col: usize, ui: &mut egui::Ui, ) -> Option { - let timeline_response = - ProfileView::new(pubkey, col, profiles, ndb, note_cache, img_cache).ui(ui); + let timeline_response = ProfileView::new( + pubkey, + col, + profiles, + ndb, + note_cache, + img_cache, + NoteOptions::default(), + ) + .ui(ui); if let Some(bar_action) = timeline_response.bar_action { let txn = nostrdb::Transaction::new(ndb).expect("txn"); let mut cur_column = columns.columns_mut(); diff --git a/src/ui/note/contents.rs b/src/ui/note/contents.rs index 1a7bf915..7916797a 100644 --- a/src/ui/note/contents.rs +++ b/src/ui/note/contents.rs @@ -139,6 +139,7 @@ fn render_note_contents( let selectable = options.has_selectable_text(); let mut images: Vec = vec![]; let mut inline_note: Option<(&[u8; 32], &str)> = None; + let hide_media = options.has_hide_media(); let response = ui.horizontal_wrapped(|ui| { let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) { @@ -183,7 +184,7 @@ fn render_note_contents( BlockType::Url => { let lower_url = block.as_str().to_lowercase(); - if lower_url.ends_with("png") || lower_url.ends_with("jpg") { + if !hide_media && (lower_url.ends_with("png") || lower_url.ends_with("jpg")) { images.push(block.as_str().to_string()); } else { #[cfg(feature = "profiling")] diff --git a/src/ui/note/mod.rs b/src/ui/note/mod.rs index b328f5cf..a8d0b209 100644 --- a/src/ui/note/mod.rs +++ b/src/ui/note/mod.rs @@ -204,6 +204,11 @@ impl<'a> NoteView<'a> { } } + pub fn note_options(mut self, options: NoteOptions) -> Self { + *self.options_mut() = options; + self + } + pub fn textmode(mut self, enable: bool) -> Self { self.options_mut().set_textmode(enable); self diff --git a/src/ui/note/options.rs b/src/ui/note/options.rs index 12cf04fe..1090d154 100644 --- a/src/ui/note/options.rs +++ b/src/ui/note/options.rs @@ -14,6 +14,13 @@ bitflags! { const selectable_text = 0b0000000000100000; const textmode = 0b0000000001000000; const options_button = 0b0000000010000000; + const hide_media = 0b0000000100000000; + } +} + +impl Default for NoteOptions { + fn default() -> NoteOptions { + NoteOptions::options_button | NoteOptions::note_previews | NoteOptions::actionbar } } @@ -39,12 +46,24 @@ impl NoteOptions { create_setter!(set_actionbar, actionbar); create_setter!(set_wide, wide); create_setter!(set_options_button, options_button); + create_setter!(set_hide_media, hide_media); + + pub fn new(is_universe_timeline: bool) -> Self { + let mut options = NoteOptions::default(); + options.set_hide_media(is_universe_timeline); + options + } #[inline] pub fn has_actionbar(self) -> bool { (self & NoteOptions::actionbar) == NoteOptions::actionbar } + #[inline] + pub fn has_hide_media(self) -> bool { + (self & NoteOptions::hide_media) == NoteOptions::hide_media + } + #[inline] pub fn has_selectable_text(self) -> bool { (self & NoteOptions::selectable_text) == NoteOptions::selectable_text diff --git a/src/ui/profile/mod.rs b/src/ui/profile/mod.rs index f9de10cb..cc03de3f 100644 --- a/src/ui/profile/mod.rs +++ b/src/ui/profile/mod.rs @@ -1,6 +1,7 @@ pub mod picture; pub mod preview; +use crate::ui::note::NoteOptions; use egui::{ScrollArea, Widget}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; @@ -18,6 +19,7 @@ pub struct ProfileView<'a> { pubkey: &'a Pubkey, col_id: usize, profiles: &'a mut NotesHolderStorage, + note_options: NoteOptions, ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, @@ -31,6 +33,7 @@ impl<'a> ProfileView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, + note_options: NoteOptions, ) -> Self { ProfileView { pubkey, @@ -39,6 +42,7 @@ impl<'a> ProfileView<'a> { ndb, note_cache, img_cache, + note_options, } } @@ -59,10 +63,12 @@ impl<'a> ProfileView<'a> { profile.timeline.selected_view = tabs_ui(ui); + let reversed = false; + TimelineTabView::new( profile.timeline.current_view(), - false, - false, + reversed, + self.note_options, &txn, self.ndb, self.note_cache, diff --git a/src/ui/thread.rs b/src/ui/thread.rs index 6cf6b48e..689dd554 100644 --- a/src/ui/thread.rs +++ b/src/ui/thread.rs @@ -4,6 +4,7 @@ use crate::{ notecache::NoteCache, notes_holder::{NotesHolder, NotesHolderStorage}, thread::Thread, + ui::note::NoteOptions, }; use nostrdb::{Ndb, NoteKey, Transaction}; use tracing::error; @@ -102,10 +103,15 @@ impl<'a> ThreadView<'a> { error!("Thread::poll_notes_into_view: {e}"); } + // This is threadview. We are not the universe view... + let is_universe = false; + let mut note_options = NoteOptions::new(is_universe); + note_options.set_textmode(self.textmode); + TimelineTabView::new( thread.view(), true, - self.textmode, + note_options, &txn, self.ndb, self.note_cache, diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index 1c2d33c1..b0f7fb12 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -2,6 +2,7 @@ use crate::actionbar::{BarAction, NoteActionResponse}; use crate::timeline::TimelineTab; use crate::{ column::Columns, imgcache::ImageCache, notecache::NoteCache, timeline::TimelineId, ui, + ui::note::NoteOptions, }; use egui::containers::scroll_area::ScrollBarVisibility; use egui::{Direction, Layout}; @@ -15,7 +16,7 @@ pub struct TimelineView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, - textmode: bool, + note_options: NoteOptions, reverse: bool, } @@ -26,7 +27,7 @@ impl<'a> TimelineView<'a> { ndb: &'a Ndb, note_cache: &'a mut NoteCache, img_cache: &'a mut ImageCache, - textmode: bool, + note_options: NoteOptions, ) -> TimelineView<'a> { let reverse = false; TimelineView { @@ -36,7 +37,7 @@ impl<'a> TimelineView<'a> { note_cache, img_cache, reverse, - textmode, + note_options, } } @@ -49,7 +50,7 @@ impl<'a> TimelineView<'a> { self.note_cache, self.img_cache, self.reverse, - self.textmode, + self.note_options, ) } @@ -68,7 +69,7 @@ fn timeline_ui( note_cache: &mut NoteCache, img_cache: &mut ImageCache, reversed: bool, - textmode: bool, + note_options: NoteOptions, ) -> NoteActionResponse { //padding(4.0, ui, |ui| ui.heading("Notifications")); /* @@ -114,7 +115,7 @@ fn timeline_ui( TimelineTabView::new( timeline.current_view(), reversed, - textmode, + note_options, &txn, ndb, note_cache, @@ -212,7 +213,7 @@ fn shrink_range_to_width(range: egui::Rangef, width: f32) -> egui::Rangef { pub struct TimelineTabView<'a> { tab: &'a TimelineTab, reversed: bool, - textmode: bool, + note_options: NoteOptions, txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, @@ -223,7 +224,7 @@ impl<'a> TimelineTabView<'a> { pub fn new( tab: &'a TimelineTab, reversed: bool, - textmode: bool, + note_options: NoteOptions, txn: &'a Transaction, ndb: &'a Ndb, note_cache: &'a mut NoteCache, @@ -233,7 +234,7 @@ impl<'a> TimelineTabView<'a> { tab, reversed, txn, - textmode, + note_options, ndb, note_cache, img_cache, @@ -270,9 +271,7 @@ impl<'a> TimelineTabView<'a> { ui::padding(8.0, ui, |ui| { let resp = ui::NoteView::new(self.ndb, self.note_cache, self.img_cache, ¬e) - .note_previews(!self.textmode) - .selectable_text(false) - .options_button(true) + .note_options(self.note_options) .show(ui); bar_action = bar_action.or(resp.action.bar_action); From de8029d60f66e4c20a371aa5aa7c53c2904950ad Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 17 Nov 2024 17:12:59 -0800 Subject: [PATCH 15/15] fix crash when removing non-last columns Fixes: https://github.com/damus-io/notedeck/issues/445 --- src/app.rs | 16 +++++++++------- src/nav.rs | 35 ++++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/app.rs b/src/app.rs index 126386ed..38302d8d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -729,8 +729,10 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { //let routes = app.timelines[0].routes.clone(); main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { - if !app.columns.columns().is_empty() && nav::render_nav(0, app, ui) { - storage::save_columns(&app.path, app.columns.as_serializable_columns()); + if !app.columns.columns().is_empty() { + if let Some(r) = nav::render_nav(0, app, ui) { + r.process_nav_response(&app.path, &mut app.columns) + } } }); } @@ -807,12 +809,12 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { ); }); - let mut columns_changed = false; + let mut nav_resp: Option = None; for col_index in 0..app.columns.num_columns() { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - if nav::render_nav(col_index, app, ui) { - columns_changed = true; + if let Some(r) = nav::render_nav(col_index, app, ui) { + nav_resp = Some(r); } // vertical line @@ -826,8 +828,8 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } - if columns_changed { - storage::save_columns(&app.path, app.columns.as_serializable_columns()); + if let Some(r) = nav_resp { + r.process_nav_response(&app.path, &mut app.columns); } }); } diff --git a/src/nav.rs b/src/nav.rs index c4ebfc7d..c417c3e0 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -1,11 +1,13 @@ use crate::{ account_manager::render_accounts_route, app_style::{get_font_size, NotedeckTextStyle}, + column::Columns, fonts::NamedFontFamily, notes_holder::NotesHolder, profile::Profile, relay_pool_manager::RelayPoolManager, route::Route, + storage::{self, DataPath}, thread::Thread, timeline::{ route::{render_profile_route, render_timeline_route, AfterRouteExecution, TimelineRoute}, @@ -27,8 +29,28 @@ use egui_nav::{Nav, NavAction, TitleBarResponse}; use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; -pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { - let mut col_changed = false; +pub enum RenderNavResponse { + ColumnChanged, + RemoveColumn(usize), +} + +impl RenderNavResponse { + pub fn process_nav_response(&self, path: &DataPath, columns: &mut Columns) { + match self { + RenderNavResponse::ColumnChanged => { + storage::save_columns(path, columns.as_serializable_columns()); + } + + RenderNavResponse::RemoveColumn(col) => { + columns.delete_column(*col); + storage::save_columns(path, columns.as_serializable_columns()); + } + } + } +} + +pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> Option { + let mut resp: Option = None; let col_id = app.columns.get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router in egui-nav directly let routes = app @@ -193,14 +215,14 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { pubkey.bytes(), ); } - col_changed = true; + resp = Some(RenderNavResponse::ColumnChanged) } else if let Some(NavAction::Navigated) = nav_response.action { let cur_router = app.columns_mut().column_mut(col).router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { cur_router.remove_previous_routes(); } - col_changed = true; + resp = Some(RenderNavResponse::ColumnChanged) } if let Some(title_response) = nav_response.title_response { @@ -210,13 +232,12 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> bool { if let Some(timeline) = tl { unsubscribe_timeline(app.ndb(), timeline); } - app.columns_mut().delete_column(col); - col_changed = true; + resp = Some(RenderNavResponse::RemoveColumn(col)) } } } - col_changed + resp } fn unsubscribe_timeline(ndb: &Ndb, timeline: &Timeline) {