commit 91016facc720a3eb2b65f907fbcc7eb0f3ff4163
parent 94b97d247d204073727ca107ff5a433a520fed4c
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 13 Nov 2024 13:13:41 -0800
Merge Persist Columns #390
Fixed a few merge conflicts
kernelkind (2):
      initial column storage
      tmp remove DeckAuthor columns
Diffstat:
12 files changed, 208 insertions(+), 31 deletions(-)
diff --git 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
@@ -15,7 +15,7 @@ use crate::{
     notecache::{CachedNote, NoteCache},
     notes_holder::NotesHolderStorage,
     profile::Profile,
-    storage::{DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType},
+    storage::{self, DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType},
     subscriptions::{SubKind, Subscriptions},
     support::Support,
     thread::Thread,
@@ -721,12 +721,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(&path) {
+                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;
 
@@ -982,8 +998,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.path, app.columns.as_serializable_columns());
         }
     });
 }
@@ -1060,10 +1076,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(
@@ -1075,6 +1094,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.path, app.columns.as_serializable_columns());
+            }
         });
 }
 
diff --git 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<Filter>),
diff --git 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<Route>,
 }
@@ -24,6 +27,28 @@ impl Column {
     }
 }
 
+impl serde::Serialize for Column {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.router.routes().serialize(serializer)
+    }
+}
+
+impl<'de> Deserialize<'de> for Column {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let routes = Vec::<Route>::deserialize(deserializer)?;
+
+        Ok(Column {
+            router: Router::new(routes),
+        })
+    }
+}
+
 #[derive(Default)]
 pub struct Columns {
     /// Columns are simply routers into settings, timelines, etc
@@ -70,6 +95,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);
     }
@@ -196,4 +225,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<Column>,
+    pub timelines: Vec<SerializableTimeline>,
+}
+
+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
@@ -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<R> in egui-nav directly
     let routes = app
@@ -191,12 +192,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_routes();
         }
+        col_changed = true;
     }
 
     if let Some(title_response) = nav_response.title_response {
@@ -210,6 +213,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
@@ -1,5 +1,6 @@
 use enostr::{NoteId, Pubkey};
 use nostrdb::Ndb;
+use serde::{Deserialize, Serialize};
 use std::fmt::{self};
 
 use crate::{
@@ -13,7 +14,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
@@ -0,0 +1,48 @@
+use tracing::{error, info};
+
+use crate::column::SerializableColumns;
+
+use super::{write_file, DataPath, DataPathType, Directory};
+
+static COLUMNS_FILE: &str = "columns.json";
+
+pub fn save_columns(path: &DataPath, 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 = path.path(DataPathType::Setting);
+
+    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(path: &DataPath) -> Option<SerializableColumns> {
+    let data_path = path.path(DataPathType::Setting);
+
+    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::<SerializableColumns>(&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
@@ -1,6 +1,8 @@
+mod columns;
 mod file_key_storage;
 mod file_storage;
 
+pub use columns::{load_columns, save_columns};
 pub use file_key_storage::FileKeyStorage;
 pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory};
 
diff --git 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, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 pub enum PubkeySource {
     Explicit(Pubkey),
     DeckAuthor,
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub enum ListKind {
     Contact(PubkeySource),
 }
@@ -27,7 +28,7 @@ pub enum ListKind {
 ///   - filter
 ///   - ... etc
 ///
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub enum TimelineKind {
     List(ListKind),
 
diff --git 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<Subscription>,
 }
 
+#[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<Timeline> {
+        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<Self> {
@@ -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
@@ -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),
diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs
@@ -1,4 +1,5 @@
 use core::f32;
+use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
 
 use egui::{
@@ -39,7 +40,7 @@ enum AddColumnOption {
     Home(PubkeySource),
 }
 
-#[derive(Clone, Copy, Eq, PartialEq, Debug)]
+#[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)]
 pub enum AddColumnRoute {
     Base,
     UndecidedNotification,
@@ -276,11 +277,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",