notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit b8207106d71ccfd4ef30738ee86d398a68863b4f
parent 5280028a82408c1ffcf3cadfaa78ef05955ee0d6
Author: Fernando López Guevara <fernando.lguevara@gmail.com>
Date:   Wed, 23 Jul 2025 23:04:49 -0300

feat(settings): persist settings to storage

Diffstat:
Mcrates/notedeck/src/app.rs | 29+++++++++++++++++++++--------
Mcrates/notedeck/src/context.rs | 4++--
Mcrates/notedeck/src/persist/mod.rs | 2++
Acrates/notedeck/src/persist/settings_handler.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 3++-
Mcrates/notedeck_columns/src/app.rs | 9+++++++--
Mcrates/notedeck_columns/src/nav.rs | 24+++++++++++-------------
Mcrates/notedeck_columns/src/ui/mod.rs | 1+
Mcrates/notedeck_columns/src/ui/settings.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
9 files changed, 339 insertions(+), 67 deletions(-)

diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -3,12 +3,11 @@ use crate::i18n::Localization; use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::wallet::GlobalWallet; use crate::zaps::Zaps; -use crate::JobPool; use crate::{ frame_history::FrameHistory, AccountStorage, Accounts, AppContext, Args, DataPath, - DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, ThemeHandler, - UnknownIds, + DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, UnknownIds, }; +use crate::{JobPool, SettingsHandler}; use egui::Margin; use egui::ThemePreference; use egui_winit::clipboard::Clipboard; @@ -19,6 +18,7 @@ use std::collections::BTreeSet; use std::path::Path; use std::rc::Rc; use tracing::{error, info}; +use unic_langid::{LanguageIdentifier, LanguageIdentifierError}; pub enum AppAction { Note(NoteAction), @@ -40,7 +40,7 @@ pub struct Notedeck { global_wallet: GlobalWallet, path: DataPath, args: Args, - theme: ThemeHandler, + settings_handler: SettingsHandler, app: Option<Rc<RefCell<dyn App>>>, zoom: ZoomHandler, app_size: AppSizeHandler, @@ -159,7 +159,10 @@ impl Notedeck { 1024usize * 1024usize * 1024usize * 1024usize }; - let theme = ThemeHandler::new(&path); + let mut settings_handler = SettingsHandler::new(&path); + + settings_handler.load(); + let config = Config::new().set_ingester_threads(2).set_mapsize(map_size); let keystore = if parsed_args.use_keystore { @@ -231,6 +234,16 @@ impl Notedeck { // Initialize localization let mut i18n = Localization::new(); + + let setting_locale: Result<LanguageIdentifier, LanguageIdentifierError> = + settings_handler.locale().parse(); + + if setting_locale.is_ok() { + if let Err(err) = i18n.set_locale(setting_locale.unwrap()) { + error!("{err}"); + } + } + if let Some(locale) = &parsed_args.locale { if let Err(err) = i18n.set_locale(locale.to_owned()) { error!("{err}"); @@ -250,7 +263,7 @@ impl Notedeck { global_wallet, path: path.clone(), args: parsed_args, - theme, + settings_handler, app: None, zoom, app_size, @@ -279,7 +292,7 @@ impl Notedeck { global_wallet: &mut self.global_wallet, path: &self.path, args: &self.args, - theme: &mut self.theme, + settings_handler: &mut self.settings_handler, clipboard: &mut self.clipboard, zaps: &mut self.zaps, frame_history: &mut self.frame_history, @@ -297,7 +310,7 @@ impl Notedeck { } pub fn theme(&self) -> ThemePreference { - self.theme.load() + self.settings_handler.theme() } pub fn unrecognized_args(&self) -> &BTreeSet<String> { diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -1,6 +1,6 @@ use crate::{ account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization, - wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler, + wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, SettingsHandler, UnknownIds, }; use egui_winit::clipboard::Clipboard; @@ -20,7 +20,7 @@ pub struct AppContext<'a> { pub global_wallet: &'a mut GlobalWallet, pub path: &'a DataPath, pub args: &'a Args, - pub theme: &'a mut ThemeHandler, + pub settings_handler: &'a mut SettingsHandler, pub clipboard: &'a mut Clipboard, pub zaps: &'a mut Zaps, pub frame_history: &'a mut FrameHistory, diff --git a/crates/notedeck/src/persist/mod.rs b/crates/notedeck/src/persist/mod.rs @@ -1,9 +1,11 @@ mod app_size; +mod settings_handler; mod theme_handler; mod token_handler; mod zoom; pub use app_size::AppSizeHandler; +pub use settings_handler::SettingsHandler; pub use theme_handler::ThemeHandler; pub use token_handler::TokenHandler; pub use zoom::ZoomHandler; diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs @@ -0,0 +1,208 @@ +use crate::{ + storage::{self, delete_file}, + DataPath, DataPathType, Directory, +}; +use egui::ThemePreference; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +const THEME_FILE: &str = "theme.txt"; +const SETTINGS_FILE: &str = "settings.json"; + +const DEFAULT_THEME: ThemePreference = ThemePreference::Dark; +const DEFAULT_LOCALE: &str = "es-US"; +const DEFAULT_ZOOM_FACTOR: f32 = 1.0; +const DEFAULT_SHOW_SOURCE_CLIENT: &str = "hide"; + +fn deserialize_theme(serialized_theme: &str) -> Option<ThemePreference> { + match serialized_theme { + "dark" => Some(ThemePreference::Dark), + "light" => Some(ThemePreference::Light), + "system" => Some(ThemePreference::System), + _ => None, + } +} + +#[derive(Serialize, Deserialize)] +pub struct Settings { + pub theme: ThemePreference, + pub locale: String, + pub zoom_factor: f32, + pub show_source_client: String, +} + +impl Default for Settings { + fn default() -> Self { + // Use the same fallback theme as before + Self { + theme: DEFAULT_THEME, + locale: DEFAULT_LOCALE.to_string(), + zoom_factor: DEFAULT_ZOOM_FACTOR, + show_source_client: "Hide".to_string(), + } + } +} +pub struct SettingsHandler { + directory: Directory, + current_settings: Option<Settings>, +} + +impl SettingsHandler { + fn read_legacy_theme(&self) -> Option<ThemePreference> { + match self.directory.get_file(THEME_FILE.to_string()) { + Ok(contents) => deserialize_theme(contents.trim()), + Err(_) => None, + } + } + + fn migrate_to_settings_file(&mut self) -> Result<(), ()> { + // if theme.txt exists migrate + if let Some(theme_from_file) = self.read_legacy_theme() { + info!("migrating theme preference from theme.txt file"); + _ = delete_file(&self.directory.file_path, THEME_FILE.to_string()); + + self.current_settings = Some(Settings { + theme: theme_from_file, + ..Settings::default() + }); + + self.save(); + + Ok(()) + } else { + Err(()) + } + } + + pub fn new(path: &DataPath) -> Self { + let directory = Directory::new(path.path(DataPathType::Setting)); + let current_settings: Option<Settings> = None; + + Self { + directory, + current_settings, + } + } + + pub fn load(&mut self) { + if self.migrate_to_settings_file().is_ok() { + return; + } + + match self.directory.get_file(SETTINGS_FILE.to_string()) { + Ok(contents_str) => { + // Parse JSON content + match serde_json::from_str::<Settings>(&contents_str) { + Ok(settings) => { + self.current_settings = Some(settings); + } + Err(_) => { + error!("Invalid settings format. Using defaults"); + self.current_settings = Some(Settings::default()); + } + } + } + Err(_) => { + error!("Could not read settings. Using defaults"); + self.current_settings = Some(Settings::default()); + } + } + } + + pub fn save(&self) { + let settings = self.current_settings.as_ref().unwrap(); + match serde_json::to_string(settings) { + Ok(serialized) => { + if let Err(e) = storage::write_file( + &self.directory.file_path, + SETTINGS_FILE.to_string(), + &serialized, + ) { + error!("Could not save settings: {}", e); + } else { + info!("Settings saved successfully"); + } + } + Err(e) => error!("Failed to serialize settings: {}", e), + }; + } + + fn get_settings_mut(&mut self) -> &mut Settings { + if self.current_settings.is_none() { + self.current_settings = Some(Settings::default()); + } + self.current_settings.as_mut().unwrap() + } + + pub fn set_theme(&mut self, theme: ThemePreference) { + self.get_settings_mut().theme = theme; + self.save(); + } + + pub fn set_locale<S>(&mut self, locale: S) + where + S: Into<String>, + { + self.get_settings_mut().locale = locale.into(); + self.save(); + } + + pub fn set_zoom_factor(&mut self, zoom_factor: f32) { + self.get_settings_mut().zoom_factor = zoom_factor; + self.save(); + } + + pub fn set_show_source_client<S>(&mut self, option: S) + where + S: Into<String>, + { + self.get_settings_mut().show_source_client = option.into(); + self.save(); + } + + pub fn update_batch<F>(&mut self, update_fn: F) + where + F: FnOnce(&mut Settings), + { + let settings = self.get_settings_mut(); + update_fn(settings); + self.save(); + } + + pub fn update_settings(&mut self, new_settings: Settings) { + self.current_settings = Some(new_settings); + self.save(); + } + + pub fn theme(&self) -> ThemePreference { + self.current_settings + .as_ref() + .map(|s| s.theme) + .unwrap_or(DEFAULT_THEME) + } + + pub fn locale(&self) -> String { + self.current_settings + .as_ref() + .map(|s| s.locale.clone()) + .unwrap_or_else(|| DEFAULT_LOCALE.to_string()) + } + + pub fn zoom_factor(&self) -> f32 { + self.current_settings + .as_ref() + .map(|s| s.zoom_factor) + .unwrap_or(DEFAULT_ZOOM_FACTOR) + } + + pub fn show_source_client(&self) -> String { + self.current_settings + .as_ref() + .map(|s| s.show_source_client.to_string()) + .unwrap_or(DEFAULT_SHOW_SOURCE_CLIENT.to_string()) + } + + pub fn is_loaded(&self) -> bool { + self.current_settings.is_some() + } +} diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -115,7 +115,8 @@ impl ChromePanelAction { ui.ctx().options_mut(|o| { o.theme_preference = *theme; }); - ctx.theme.save(*theme); + ctx.settings_handler.set_theme(*theme); + ctx.settings_handler.save(); } Self::Toolbar(toolbar_action) => match toolbar_action { diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -15,6 +15,8 @@ use crate::{ Result, }; +use crate::ui::settings::ShowNoteClientOption; + use egui_extras::{Size, StripBuilder}; use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use nostrdb::Transaction; @@ -506,11 +508,14 @@ impl Damus { ); note_options.set( NoteOptions::ShowNoteClientTop, - parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientTop), + ShowNoteClientOption::Top == app_context.settings_handler.show_source_client().into() + || parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientTop), ); note_options.set( NoteOptions::ShowNoteClientBottom, - parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientBottom), + ShowNoteClientOption::Bottom + == app_context.settings_handler.show_source_client().into() + || parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientBottom), ); options.set(AppOptions::Debug, app_context.args.debug); options.set( diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -21,7 +21,7 @@ use crate::{ note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType, QuoteRepostView}, profile::EditProfileView, search::{FocusState, SearchView}, - settings::{SettingsAction, ShowNoteClientOptions}, + settings::SettingsAction, support::SupportView, wallet::{get_default_zap_state, WalletAction, WalletState, WalletView}, AccountsView, PostReplyView, PostView, ProfileView, RelayView, SettingsView, ThreadView, @@ -30,6 +30,8 @@ use crate::{ Damus, }; +use crate::ui::settings::ShowNoteClientOption; + use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, PopupSheet}; use enostr::ProfileState; use nostrdb::{Filter, Ndb, Transaction}; @@ -37,7 +39,6 @@ use notedeck::{ get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteContext, RelayAction, }; -use notedeck_ui::NoteOptions; use tracing::error; /// The result of processing a nav response @@ -486,9 +487,13 @@ fn process_render_nav_action( .process_relay_action(ui.ctx(), ctx.pool, action); None } - RenderNavAction::SettingsAction(action) => { - action.process_settings_action(app, ctx.theme, ctx.i18n, ctx.img_cache, ui.ctx()) - } + RenderNavAction::SettingsAction(action) => action.process_settings_action( + app, + ctx.settings_handler, + ctx.i18n, + ctx.img_cache, + ui.ctx(), + ), }; if let Some(action) = router_action { @@ -583,14 +588,7 @@ fn render_nav_body( .map(RenderNavAction::RelayAction), Route::Settings => { - let mut show_note_client = if app.note_options.contains(NoteOptions::ShowNoteClientTop) - { - ShowNoteClientOptions::Top - } else if app.note_options.contains(NoteOptions::ShowNoteClientBottom) { - ShowNoteClientOptions::Bottom - } else { - ShowNoteClientOptions::Hide - }; + let mut show_note_client: ShowNoteClientOption = app.note_options.into(); let mut theme: String = (if ui.visuals().dark_mode { "Dark" diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -26,6 +26,7 @@ pub use preview::{Preview, PreviewApp, PreviewConfig}; pub use profile::ProfileView; pub use relay::RelayView; pub use settings::SettingsView; +pub use settings::ShowNoteClientOption; pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use thread::ThreadView; pub use timeline::TimelineView; diff --git a/crates/notedeck_columns/src/ui/settings.rs b/crates/notedeck_columns/src/ui/settings.rs @@ -1,21 +1,73 @@ use egui::{vec2, Button, Color32, ComboBox, Frame, Margin, RichText, ThemePreference}; -use notedeck::{tr, Images, LanguageIdentifier, Localization, NotedeckTextStyle, ThemeHandler}; +use notedeck::{tr, Images, LanguageIdentifier, Localization, NotedeckTextStyle, SettingsHandler}; use notedeck_ui::NoteOptions; use strum::Display; use crate::{nav::RouterAction, Damus, Route}; #[derive(Clone, Copy, PartialEq, Eq, Display)] -pub enum ShowNoteClientOptions { +pub enum ShowNoteClientOption { Hide, Top, Bottom, } +impl From<ShowNoteClientOption> for String { + fn from(value: ShowNoteClientOption) -> Self { + match value { + ShowNoteClientOption::Hide => "hide".to_string(), + ShowNoteClientOption::Top => "top".to_string(), + ShowNoteClientOption::Bottom => "bottom".to_string(), + } + } +} + +impl From<NoteOptions> for ShowNoteClientOption { + fn from(note_options: NoteOptions) -> Self { + if note_options.contains(NoteOptions::ShowNoteClientTop) { + ShowNoteClientOption::Top + } else if note_options.contains(NoteOptions::ShowNoteClientBottom) { + ShowNoteClientOption::Bottom + } else { + ShowNoteClientOption::Hide + } + } +} + +impl From<String> for ShowNoteClientOption { + fn from(s: String) -> Self { + match s.to_lowercase().as_str() { + "hide" => Self::Hide, + "top" => Self::Top, + "bottom" => Self::Bottom, + _ => Self::Hide, // default fallback + } + } +} + +impl ShowNoteClientOption { + pub fn set_note_options(self, note_options: &mut NoteOptions) { + match self { + Self::Hide => { + note_options.set(NoteOptions::ShowNoteClientTop, false); + note_options.set(NoteOptions::ShowNoteClientBottom, false); + } + Self::Bottom => { + note_options.set(NoteOptions::ShowNoteClientTop, false); + note_options.set(NoteOptions::ShowNoteClientBottom, true); + } + Self::Top => { + note_options.set(NoteOptions::ShowNoteClientTop, true); + note_options.set(NoteOptions::ShowNoteClientBottom, false); + } + } + } +} + pub enum SettingsAction { - SetZoom(f32), + SetZoomFactor(f32), SetTheme(ThemePreference), - SetShowNoteClient(ShowNoteClientOptions), + SetShowSourceClient(ShowNoteClientOption), SetLocale(LanguageIdentifier), OpenRelays, OpenCacheFolder, @@ -26,7 +78,7 @@ impl SettingsAction { pub fn process_settings_action<'a>( self, app: &mut Damus, - theme_handler: &'a mut ThemeHandler, + settings_handler: &'a mut SettingsHandler, i18n: &'a mut Localization, img_cache: &mut Images, ctx: &egui::Context, @@ -37,34 +89,25 @@ impl SettingsAction { SettingsAction::OpenRelays => { route_action = Some(RouterAction::route_to(Route::Relays)); } - SettingsAction::SetZoom(zoom_level) => { - ctx.set_zoom_factor(zoom_level); + SettingsAction::SetZoomFactor(zoom_factor) => { + ctx.set_zoom_factor(zoom_factor); + settings_handler.set_zoom_factor(zoom_factor); + } + SettingsAction::SetShowSourceClient(option) => { + option.set_note_options(&mut app.note_options); + + settings_handler.set_show_source_client(option); } - SettingsAction::SetShowNoteClient(newvalue) => match newvalue { - ShowNoteClientOptions::Hide => { - app.note_options.set(NoteOptions::ShowNoteClientTop, false); - app.note_options - .set(NoteOptions::ShowNoteClientBottom, false); - } - ShowNoteClientOptions::Bottom => { - app.note_options.set(NoteOptions::ShowNoteClientTop, false); - app.note_options - .set(NoteOptions::ShowNoteClientBottom, true); - } - ShowNoteClientOptions::Top => { - app.note_options.set(NoteOptions::ShowNoteClientTop, true); - app.note_options - .set(NoteOptions::ShowNoteClientBottom, false); - } - }, SettingsAction::SetTheme(theme) => { ctx.options_mut(|o| { o.theme_preference = theme; }); - theme_handler.save(theme); + settings_handler.set_theme(theme); } SettingsAction::SetLocale(language) => { - _ = i18n.set_locale(language); + if i18n.set_locale(language.clone()).is_ok() { + settings_handler.set_locale(language.to_string()); + } } SettingsAction::OpenCacheFolder => { use opener; @@ -74,6 +117,7 @@ impl SettingsAction { let _ = img_cache.clear_folder_contents(); } } + settings_handler.save(); route_action } } @@ -81,7 +125,7 @@ impl SettingsAction { pub struct SettingsView<'a> { theme: &'a mut String, selected_language: &'a mut String, - show_note_client: &'a mut ShowNoteClientOptions, + show_note_client: &'a mut ShowNoteClientOption, i18n: &'a mut Localization, img_cache: &'a mut Images, } @@ -91,7 +135,7 @@ impl<'a> SettingsView<'a> { img_cache: &'a mut Images, selected_language: &'a mut String, theme: &'a mut String, - show_note_client: &'a mut ShowNoteClientOptions, + show_note_client: &'a mut ShowNoteClientOption, i18n: &'a mut Localization, ) -> Self { Self { @@ -115,20 +159,20 @@ impl<'a> SettingsView<'a> { } } - /// Get the localized label for ShowNoteClientOptions - fn get_show_note_client_label(&mut self, option: ShowNoteClientOptions) -> String { + /// Get the localized label for ShowNoteClientOption + fn get_show_note_client_label(&mut self, option: ShowNoteClientOption) -> String { match option { - ShowNoteClientOptions::Hide => tr!( + ShowNoteClientOption::Hide => tr!( self.i18n, "Hide", "Option in settings section to hide the source client label in note display" ), - ShowNoteClientOptions::Top => tr!( + ShowNoteClientOption::Top => tr!( self.i18n, "Top", "Option in settings section to show the source client label at the top of the note" ), - ShowNoteClientOptions::Bottom => tr!( + ShowNoteClientOption::Bottom => tr!( self.i18n, "Bottom", "Option in settings section to show the source client label at the bottom of the note" @@ -179,7 +223,7 @@ impl<'a> SettingsView<'a> { .clicked() { let new_zoom = (current_zoom - 0.1).max(0.1); - action = Some(SettingsAction::SetZoom(new_zoom)); + action = Some(SettingsAction::SetZoomFactor(new_zoom)); }; ui.label( @@ -195,7 +239,7 @@ impl<'a> SettingsView<'a> { .clicked() { let new_zoom = (current_zoom + 0.1).min(10.0); - action = Some(SettingsAction::SetZoom(new_zoom)); + action = Some(SettingsAction::SetZoomFactor(new_zoom)); }; if ui @@ -209,7 +253,7 @@ impl<'a> SettingsView<'a> { ) .clicked() { - action = Some(SettingsAction::SetZoom(1.0)); + action = Some(SettingsAction::SetZoomFactor(1.0)); } }); @@ -336,7 +380,7 @@ impl<'a> SettingsView<'a> { ui.end_row(); if !notedeck::ui::is_compiled_as_mobile() && - ui.button(RichText::new(tr!(self.i18n, "View folder:", "Label for view folder button, Storage settings section")) + ui.button(RichText::new(tr!(self.i18n, "View folder", "Label for view folder button, Storage settings section")) .text_style(NotedeckTextStyle::Small.text_style())).clicked() { action = Some(SettingsAction::OpenCacheFolder); } @@ -419,9 +463,9 @@ impl<'a> SettingsView<'a> { ); for option in [ - ShowNoteClientOptions::Hide, - ShowNoteClientOptions::Top, - ShowNoteClientOptions::Bottom, + ShowNoteClientOption::Hide, + ShowNoteClientOption::Top, + ShowNoteClientOption::Bottom, ] { let label = self.get_show_note_client_label(option); @@ -434,7 +478,7 @@ impl<'a> SettingsView<'a> { ) .changed() { - action = Some(SettingsAction::SetShowNoteClient(option)); + action = Some(SettingsAction::SetShowSourceClient(option)); } } });