notedeck

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

commit a896a6ecfa12b0af6b7f4fe6337a0dd93bb1f139
parent f28236374839e18690bd884e3f88325972c8903c
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 31 Jul 2025 11:48:57 -0700

Merge remote-tracking branch 'fernando/feat/persist_settings'

Diffstat:
Mcrates/notedeck/src/app.rs | 44++++++++++++++++++++++++++------------------
Mcrates/notedeck/src/context.rs | 2+-
Mcrates/notedeck/src/fonts.rs | 2++
Mcrates/notedeck/src/persist/mod.rs | 6++----
Mcrates/notedeck/src/persist/settings_handler.rs | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Dcrates/notedeck/src/persist/theme_handler.rs | 76----------------------------------------------------------------------------
Dcrates/notedeck/src/persist/zoom.rs | 26--------------------------
Mcrates/notedeck/src/style.rs | 3+++
Mcrates/notedeck/src/timed_serializer.rs | 16++++++++--------
Mcrates/notedeck/src/ui.rs | 13++++++++++++-
Mcrates/notedeck_chrome/src/android.rs | 8+++++++-
Mcrates/notedeck_chrome/src/chrome.rs | 7++-----
Mcrates/notedeck_chrome/src/notedeck.rs | 8+++++++-
Mcrates/notedeck_chrome/src/preview.rs | 8+++++++-
Mcrates/notedeck_chrome/src/setup.rs | 22++++++++++++++++++----
Mcrates/notedeck_columns/src/app.rs | 78++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/notedeck_columns/src/nav.rs | 42+++++++++++-------------------------------
Mcrates/notedeck_columns/src/ui/mod.rs | 2+-
Mcrates/notedeck_columns/src/ui/settings.rs | 879+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mcrates/notedeck_columns/src/ui/thread.rs | 16++++++++++++++--
Mcrates/notedeck_ui/src/mention.rs | 6++++--
Mcrates/notedeck_ui/src/note/contents.rs | 55++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/notedeck_ui/src/note/media.rs | 124+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_ui/src/note/mod.rs | 7++++++-
Mcrates/notedeck_ui/src/note/options.rs | 2++
25 files changed, 871 insertions(+), 714 deletions(-)

diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -1,13 +1,13 @@ use crate::account::FALLBACK_PUBKEY; use crate::i18n::Localization; -use crate::persist::{AppSizeHandler, ZoomHandler}; +use crate::persist::{AppSizeHandler, SettingsHandler}; 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, UnknownIds, }; -use crate::{JobPool, SettingsHandler}; use egui::Margin; use egui::ThemePreference; use egui_winit::clipboard::Clipboard; @@ -40,9 +40,8 @@ pub struct Notedeck { global_wallet: GlobalWallet, path: DataPath, args: Args, - settings_handler: SettingsHandler, + settings: SettingsHandler, app: Option<Rc<RefCell<dyn App>>>, - zoom: ZoomHandler, app_size: AppSizeHandler, unrecognized_args: BTreeSet<String>, clipboard: Clipboard, @@ -99,7 +98,15 @@ impl eframe::App for Notedeck { render_notedeck(self, ctx); - self.zoom.try_save_zoom_factor(ctx); + self.settings.update_batch(|settings| { + settings.zoom_factor = ctx.zoom_factor(); + settings.locale = self.i18n.get_current_locale().to_string(); + settings.theme = if ctx.style().visuals.dark_mode { + ThemePreference::Dark + } else { + ThemePreference::Light + }; + }); self.app_size.try_save_app_size(ctx); if self.args.relay_debug { @@ -159,9 +166,7 @@ impl Notedeck { 1024usize * 1024usize * 1024usize * 1024usize }; - let mut settings_handler = SettingsHandler::new(&path); - - settings_handler.load(); + let settings = SettingsHandler::new(&path).load(); let config = Config::new().set_ingester_threads(2).set_mapsize(map_size); @@ -216,12 +221,8 @@ impl Notedeck { let img_cache = Images::new(img_cache_dir); let note_cache = NoteCache::default(); - let zoom = ZoomHandler::new(&path); - let app_size = AppSizeHandler::new(&path); - if let Some(z) = zoom.get_zoom_factor() { - ctx.set_zoom_factor(z); - } + let app_size = AppSizeHandler::new(&path); // migrate if let Err(e) = img_cache.migrate_v0() { @@ -236,7 +237,7 @@ impl Notedeck { let mut i18n = Localization::new(); let setting_locale: Result<LanguageIdentifier, LanguageIdentifierError> = - settings_handler.locale().parse(); + settings.locale().parse(); if setting_locale.is_ok() { if let Err(err) = i18n.set_locale(setting_locale.unwrap()) { @@ -263,9 +264,8 @@ impl Notedeck { global_wallet, path: path.clone(), args: parsed_args, - settings_handler, + settings, app: None, - zoom, app_size, unrecognized_args, frame_history: FrameHistory::default(), @@ -292,7 +292,7 @@ impl Notedeck { global_wallet: &mut self.global_wallet, path: &self.path, args: &self.args, - settings_handler: &mut self.settings_handler, + settings: &mut self.settings, clipboard: &mut self.clipboard, zaps: &mut self.zaps, frame_history: &mut self.frame_history, @@ -310,7 +310,15 @@ impl Notedeck { } pub fn theme(&self) -> ThemePreference { - self.settings_handler.theme() + self.settings.theme() + } + + pub fn note_body_font_size(&self) -> f32 { + self.settings.note_body_font_size() + } + + pub fn zoom_factor(&self) -> f32 { + self.settings.zoom_factor() } pub fn unrecognized_args(&self) -> &BTreeSet<String> { diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -20,7 +20,7 @@ pub struct AppContext<'a> { pub global_wallet: &'a mut GlobalWallet, pub path: &'a DataPath, pub args: &'a Args, - pub settings_handler: &'a mut SettingsHandler, + pub settings: &'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/fonts.rs b/crates/notedeck/src/fonts.rs @@ -31,6 +31,7 @@ pub fn desktop_font_size(text_style: &NotedeckTextStyle) -> f32 { NotedeckTextStyle::Button => 13.0, NotedeckTextStyle::Small => 12.0, NotedeckTextStyle::Tiny => 10.0, + NotedeckTextStyle::NoteBody => 16.0, } } @@ -46,6 +47,7 @@ pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 { NotedeckTextStyle::Button => 13.0, NotedeckTextStyle::Small => 12.0, NotedeckTextStyle::Tiny => 10.0, + NotedeckTextStyle::NoteBody => 13.0, } } diff --git a/crates/notedeck/src/persist/mod.rs b/crates/notedeck/src/persist/mod.rs @@ -1,11 +1,9 @@ mod app_size; mod settings_handler; -mod theme_handler; mod token_handler; -mod zoom; pub use app_size::AppSizeHandler; +pub use settings_handler::Settings; pub use settings_handler::SettingsHandler; -pub use theme_handler::ThemeHandler; +pub use settings_handler::DEFAULT_NOTE_BODY_FONT_SIZE; 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 @@ -1,18 +1,23 @@ use crate::{ - storage::{self, delete_file}, - DataPath, DataPathType, Directory, + storage::delete_file, timed_serializer::TimedSerializer, DataPath, DataPathType, Directory, }; use egui::ThemePreference; use serde::{Deserialize, Serialize}; use tracing::{error, info}; const THEME_FILE: &str = "theme.txt"; +const ZOOM_FACTOR_FILE: &str = "zoom_level.json"; const SETTINGS_FILE: &str = "settings.json"; const DEFAULT_THEME: ThemePreference = ThemePreference::Dark; -const DEFAULT_LOCALE: &str = "es-US"; +const DEFAULT_LOCALE: &str = "en-US"; const DEFAULT_ZOOM_FACTOR: f32 = 1.0; const DEFAULT_SHOW_SOURCE_CLIENT: &str = "hide"; +const DEFAULT_SHOW_REPLIES_NEWEST_FIRST: bool = false; +#[cfg(any(target_os = "android", target_os = "ios"))] +pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 13.0; +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 16.0; fn deserialize_theme(serialized_theme: &str) -> Option<ThemePreference> { match serialized_theme { @@ -23,70 +28,97 @@ fn deserialize_theme(serialized_theme: &str) -> Option<ThemePreference> { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Clone)] pub struct Settings { pub theme: ThemePreference, pub locale: String, pub zoom_factor: f32, pub show_source_client: String, + pub show_replies_newest_first: bool, + pub note_body_font_size: f32, } 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(), + show_source_client: DEFAULT_SHOW_SOURCE_CLIENT.to_string(), + show_replies_newest_first: DEFAULT_SHOW_REPLIES_NEWEST_FIRST, + note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE, } } } + pub struct SettingsHandler { directory: Directory, + serializer: TimedSerializer<Settings>, current_settings: Option<Settings>, } impl SettingsHandler { - fn read_legacy_theme(&self) -> Option<ThemePreference> { + fn read_from_theme_file(&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<(), ()> { + fn read_from_zomfactor_file(&self) -> Option<f32> { + match self.directory.get_file(ZOOM_FACTOR_FILE.to_string()) { + Ok(contents) => serde_json::from_str::<f32>(&contents).ok(), + Err(_) => None, + } + } + + fn migrate_to_settings_file(&mut self) -> bool { + let mut settings = Settings::default(); + let mut migrated = false; // if theme.txt exists migrate - if let Some(theme_from_file) = self.read_legacy_theme() { + if let Some(theme_from_file) = self.read_from_theme_file() { 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() - }); + settings.theme = theme_from_file; + migrated = true; + } else { + info!("theme.txt file not found, using default theme"); + }; - self.save(); + // if zoom_factor.txt exists migrate + if let Some(zom_factor) = self.read_from_zomfactor_file() { + info!("migrating theme preference from zom_factor file"); + _ = delete_file(&self.directory.file_path, ZOOM_FACTOR_FILE.to_string()); - Ok(()) + settings.zoom_factor = zom_factor; + migrated = true; } else { - Err(()) + info!("zoom_factor.txt exists migrate file not found, using default zoom factor"); + }; + + if migrated { + self.current_settings = Some(settings); + self.try_save_settings(); } + migrated } pub fn new(path: &DataPath) -> Self { let directory = Directory::new(path.path(DataPathType::Setting)); - let current_settings: Option<Settings> = None; + let serializer = + TimedSerializer::new(path, DataPathType::Setting, "settings.json".to_owned()); Self { directory, - current_settings, + serializer, + current_settings: None, } } - pub fn load(&mut self) { - if self.migrate_to_settings_file().is_ok() { - return; + pub fn load(mut self) -> Self { + if self.migrate_to_settings_file() { + return self; } match self.directory.get_file(SETTINGS_FILE.to_string()) { @@ -107,27 +139,16 @@ impl SettingsHandler { self.current_settings = Some(Settings::default()); } } + + self } - 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), - }; + pub(crate) fn try_save_settings(&mut self) { + let settings = self.get_settings_mut().clone(); + self.serializer.try_save(settings); } - fn get_settings_mut(&mut self) -> &mut Settings { + pub fn get_settings_mut(&mut self) -> &mut Settings { if self.current_settings.is_none() { self.current_settings = Some(Settings::default()); } @@ -136,7 +157,7 @@ impl SettingsHandler { pub fn set_theme(&mut self, theme: ThemePreference) { self.get_settings_mut().theme = theme; - self.save(); + self.try_save_settings(); } pub fn set_locale<S>(&mut self, locale: S) @@ -144,12 +165,12 @@ impl SettingsHandler { S: Into<String>, { self.get_settings_mut().locale = locale.into(); - self.save(); + self.try_save_settings(); } pub fn set_zoom_factor(&mut self, zoom_factor: f32) { self.get_settings_mut().zoom_factor = zoom_factor; - self.save(); + self.try_save_settings(); } pub fn set_show_source_client<S>(&mut self, option: S) @@ -157,7 +178,17 @@ impl SettingsHandler { S: Into<String>, { self.get_settings_mut().show_source_client = option.into(); - self.save(); + self.try_save_settings(); + } + + pub fn set_show_replies_newest_first(&mut self, value: bool) { + self.get_settings_mut().show_replies_newest_first = value; + self.try_save_settings(); + } + + pub fn set_note_body_font_size(&mut self, value: f32) { + self.get_settings_mut().note_body_font_size = value; + self.try_save_settings(); } pub fn update_batch<F>(&mut self, update_fn: F) @@ -166,12 +197,12 @@ impl SettingsHandler { { let settings = self.get_settings_mut(); update_fn(settings); - self.save(); + self.try_save_settings(); } pub fn update_settings(&mut self, new_settings: Settings) { self.current_settings = Some(new_settings); - self.save(); + self.try_save_settings(); } pub fn theme(&self) -> ThemePreference { @@ -202,7 +233,21 @@ impl SettingsHandler { .unwrap_or(DEFAULT_SHOW_SOURCE_CLIENT.to_string()) } + pub fn show_replies_newest_first(&self) -> bool { + self.current_settings + .as_ref() + .map(|s| s.show_replies_newest_first) + .unwrap_or(DEFAULT_SHOW_REPLIES_NEWEST_FIRST) + } + pub fn is_loaded(&self) -> bool { self.current_settings.is_some() } + + pub fn note_body_font_size(&self) -> f32 { + self.current_settings + .as_ref() + .map(|s| s.note_body_font_size) + .unwrap_or(DEFAULT_NOTE_BODY_FONT_SIZE) + } } diff --git a/crates/notedeck/src/persist/theme_handler.rs b/crates/notedeck/src/persist/theme_handler.rs @@ -1,76 +0,0 @@ -use egui::ThemePreference; -use tracing::{error, info}; - -use crate::{storage, DataPath, DataPathType, Directory}; - -pub struct ThemeHandler { - directory: Directory, - fallback_theme: ThemePreference, -} - -const THEME_FILE: &str = "theme.txt"; - -impl ThemeHandler { - pub fn new(path: &DataPath) -> Self { - let directory = Directory::new(path.path(DataPathType::Setting)); - let fallback_theme = ThemePreference::Dark; - Self { - directory, - fallback_theme, - } - } - - pub fn load(&self) -> ThemePreference { - match self.directory.get_file(THEME_FILE.to_owned()) { - Ok(contents) => match deserialize_theme(contents) { - Some(theme) => theme, - None => { - error!( - "Could not deserialize theme. Using fallback {:?} instead", - self.fallback_theme - ); - self.fallback_theme - } - }, - Err(e) => { - error!( - "Could not read {} file: {:?}\nUsing fallback {:?} instead", - THEME_FILE, e, self.fallback_theme - ); - self.fallback_theme - } - } - } - - pub fn save(&self, theme: ThemePreference) { - match storage::write_file( - &self.directory.file_path, - THEME_FILE.to_owned(), - &theme_to_serialized(&theme), - ) { - Ok(_) => info!( - "Successfully saved {:?} theme change to {}", - theme, THEME_FILE - ), - Err(_) => error!("Could not save {:?} theme change to {}", theme, THEME_FILE), - } - } -} - -fn theme_to_serialized(theme: &ThemePreference) -> String { - match theme { - ThemePreference::Dark => "dark", - ThemePreference::Light => "light", - ThemePreference::System => "system", - } - .to_owned() -} - -fn deserialize_theme(serialized_theme: String) -> Option<ThemePreference> { - match serialized_theme.as_str() { - "dark" => Some(ThemePreference::Dark), - "light" => Some(ThemePreference::Light), - "system" => Some(ThemePreference::System), - _ => None, - } -} diff --git a/crates/notedeck/src/persist/zoom.rs b/crates/notedeck/src/persist/zoom.rs @@ -1,26 +0,0 @@ -use crate::{DataPath, DataPathType}; -use egui::Context; - -use crate::timed_serializer::TimedSerializer; - -pub struct ZoomHandler { - serializer: TimedSerializer<f32>, -} - -impl ZoomHandler { - pub fn new(path: &DataPath) -> Self { - let serializer = - TimedSerializer::new(path, DataPathType::Setting, "zoom_level.json".to_owned()); - - Self { serializer } - } - - pub fn try_save_zoom_factor(&mut self, ctx: &Context) { - let cur_zoom_level = ctx.zoom_factor(); - self.serializer.try_save(cur_zoom_level); - } - - pub fn get_zoom_factor(&self) -> Option<f32> { - self.serializer.get_item() - } -} diff --git a/crates/notedeck/src/style.rs b/crates/notedeck/src/style.rs @@ -15,6 +15,7 @@ pub enum NotedeckTextStyle { Button, Small, Tiny, + NoteBody, } impl NotedeckTextStyle { @@ -29,6 +30,7 @@ impl NotedeckTextStyle { Self::Button => TextStyle::Button, Self::Small => TextStyle::Small, Self::Tiny => TextStyle::Name("Tiny".into()), + Self::NoteBody => TextStyle::Name("NoteBody".into()), } } @@ -43,6 +45,7 @@ impl NotedeckTextStyle { Self::Button => FontFamily::Proportional, Self::Small => FontFamily::Proportional, Self::Tiny => FontFamily::Proportional, + Self::NoteBody => FontFamily::Proportional, } } diff --git a/crates/notedeck/src/timed_serializer.rs b/crates/notedeck/src/timed_serializer.rs @@ -2,16 +2,16 @@ use crate::debouncer::Debouncer; use crate::{storage, DataPath, DataPathType, Directory}; use serde::{Deserialize, Serialize}; use std::time::Duration; -use tracing::info; // Adjust this import path as needed +use tracing::info; -pub struct TimedSerializer<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> { +pub struct TimedSerializer<T: PartialEq + Clone + Serialize + for<'de> Deserialize<'de>> { directory: Directory, file_name: String, debouncer: Debouncer, saved_item: Option<T>, } -impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerializer<T> { +impl<T: PartialEq + Clone + Serialize + for<'de> Deserialize<'de>> TimedSerializer<T> { pub fn new(path: &DataPath, path_type: DataPathType, file_name: String) -> Self { let directory = Directory::new(path.path(path_type)); let delay = Duration::from_millis(1000); @@ -30,11 +30,11 @@ impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerialize self } - // returns whether successful + /// Returns whether it actually wrote the new value pub fn try_save(&mut self, cur_item: T) -> bool { if self.debouncer.should_act() { - if let Some(saved_item) = self.saved_item { - if saved_item != cur_item { + if let Some(ref saved_item) = self.saved_item { + if *saved_item != cur_item { return self.save(cur_item); } } else { @@ -45,8 +45,8 @@ impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerialize } pub fn get_item(&self) -> Option<T> { - if self.saved_item.is_some() { - return self.saved_item; + if let Some(ref item) = self.saved_item { + return Some(item.clone()); } if let Ok(file_contents) = self.directory.get_file(self.file_name.clone()) { if let Ok(item) = serde_json::from_str::<T>(&file_contents) { diff --git a/crates/notedeck/src/ui.rs b/crates/notedeck/src/ui.rs @@ -1,8 +1,19 @@ +use crate::NotedeckTextStyle; + +pub const NARROW_SCREEN_WIDTH: f32 = 550.0; /// Determine if the screen is narrow. This is useful for detecting mobile /// contexts, but with the nuance that we may also have a wide android tablet. + +pub fn richtext_small<S>(text: S) -> egui::RichText +where + S: Into<String>, +{ + egui::RichText::new(text).text_style(NotedeckTextStyle::Small.text_style()) +} + pub fn is_narrow(ctx: &egui::Context) -> bool { let screen_size = ctx.input(|c| c.screen_rect().size()); - screen_size.x < 550.0 + screen_size.x < NARROW_SCREEN_WIDTH } pub fn is_oled() -> bool { diff --git a/crates/notedeck_chrome/src/android.rs b/crates/notedeck_chrome/src/android.rs @@ -69,7 +69,13 @@ pub async fn android_main(app: AndroidApp) { Box::new(move |cc| { let ctx = &cc.egui_ctx; let mut notedeck = Notedeck::new(ctx, path, &app_args); - setup_chrome(ctx, &notedeck.args(), notedeck.theme()); + setup_chrome( + ctx, + &notedeck.args(), + notedeck.theme(), + notedeck.note_body_font_size(), + notedeck.zoom_factor(), + ); let context = &mut notedeck.app_context(); let dave = Dave::new(cc.wgpu_render_state.as_ref()); diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -112,11 +112,8 @@ impl ChromePanelAction { fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { match self { Self::SaveTheme(theme) => { - ui.ctx().options_mut(|o| { - o.theme_preference = *theme; - }); - ctx.settings_handler.set_theme(*theme); - ctx.settings_handler.save(); + ui.ctx().set_theme(*theme); + ctx.settings.set_theme(*theme); } Self::Toolbar(toolbar_action) => match toolbar_action { diff --git a/crates/notedeck_chrome/src/notedeck.rs b/crates/notedeck_chrome/src/notedeck.rs @@ -98,7 +98,13 @@ async fn main() { let columns = Damus::new(&mut notedeck.app_context(), &args); let dave = Dave::new(cc.wgpu_render_state.as_ref()); - setup_chrome(ctx, notedeck.args(), notedeck.theme()); + setup_chrome( + ctx, + notedeck.args(), + notedeck.theme(), + notedeck.note_body_font_size(), + notedeck.zoom_factor(), + ); // ensure we recognized all the arguments let completely_unrecognized: Vec<String> = notedeck diff --git a/crates/notedeck_chrome/src/preview.rs b/crates/notedeck_chrome/src/preview.rs @@ -38,7 +38,13 @@ impl PreviewRunner { "unrecognized args: {:?}", notedeck.unrecognized_args() ); - setup_chrome(ctx, notedeck.args(), notedeck.theme()); + setup_chrome( + ctx, + notedeck.args(), + notedeck.theme(), + notedeck.note_body_font_size(), + notedeck.zoom_factor(), + ); notedeck.set_app(PreviewApp::new(preview)); diff --git a/crates/notedeck_chrome/src/setup.rs b/crates/notedeck_chrome/src/setup.rs @@ -1,12 +1,18 @@ use crate::{fonts, theme}; use eframe::NativeOptions; -use egui::ThemePreference; -use notedeck::{AppSizeHandler, DataPath}; +use egui::{FontId, ThemePreference}; +use notedeck::{AppSizeHandler, DataPath, NotedeckTextStyle}; use notedeck_ui::app_images; use tracing::info; -pub fn setup_chrome(ctx: &egui::Context, args: &notedeck::Args, theme: ThemePreference) { +pub fn setup_chrome( + ctx: &egui::Context, + args: &notedeck::Args, + theme: ThemePreference, + note_body_font_size: f32, + zoom_factor: f32, +) { let is_mobile = args .is_mobile .unwrap_or(notedeck::ui::is_compiled_as_mobile()); @@ -31,6 +37,15 @@ pub fn setup_chrome(ctx: &egui::Context, args: &notedeck::Args, theme: ThemePref ctx.set_visuals_of(egui::Theme::Light, theme::light_mode()); setup_cc(ctx, is_mobile); + + ctx.set_zoom_factor(zoom_factor); + + let mut style = (*ctx.style()).clone(); + style.text_styles.insert( + NotedeckTextStyle::NoteBody.text_style(), + FontId::proportional(note_body_font_size), + ); + ctx.set_style(style); } pub fn setup_cc(ctx: &egui::Context, is_mobile: bool) { @@ -39,7 +54,6 @@ pub fn setup_cc(ctx: &egui::Context, is_mobile: bool) { if notedeck::ui::is_compiled_as_mobile() { ctx.set_pixels_per_point(ctx.pixels_per_point() + 0.2); } - //ctx.set_pixels_per_point(1.0); // // diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -10,19 +10,16 @@ use crate::{ subscriptions::{SubKind, Subscriptions}, support::Support, timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, - ui::{self, DesktopSidePanel, SidePanelAction}, + ui::{self, DesktopSidePanel, ShowSourceClientOption, SidePanelAction}, view_state::ViewState, Result, }; - -use crate::ui::settings::ShowNoteClientOption; - use egui_extras::{Size, StripBuilder}; use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use nostrdb::Transaction; use notedeck::{ tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, - Images, JobsCache, Localization, UnknownIds, + Images, JobsCache, Localization, SettingsHandler, UnknownIds, }; use notedeck_ui::{ media::{MediaViewer, MediaViewerFlags, MediaViewerState}, @@ -445,6 +442,11 @@ impl Damus { let mut options = AppOptions::default(); let tmp_columns = !parsed_args.columns.is_empty(); options.set(AppOptions::TmpColumns, tmp_columns); + options.set(AppOptions::Debug, app_context.args.debug); + options.set( + AppOptions::SinceOptimize, + parsed_args.is_flag_set(ColumnsFlag::SinceOptimize), + ); let decks_cache = if tmp_columns { info!("DecksCache: loading from command line arguments"); @@ -489,37 +491,11 @@ impl Damus { // cache.add_deck_default(*pk); //} }; + let settings = &app_context.settings; let support = Support::new(app_context.path); - let mut note_options = NoteOptions::default(); - note_options.set( - NoteOptions::Textmode, - parsed_args.is_flag_set(ColumnsFlag::Textmode), - ); - note_options.set( - NoteOptions::ScrambleText, - parsed_args.is_flag_set(ColumnsFlag::Scramble), - ); - note_options.set( - NoteOptions::HideMedia, - parsed_args.is_flag_set(ColumnsFlag::NoMedia), - ); - note_options.set( - NoteOptions::ShowNoteClientTop, - ShowNoteClientOption::Top == app_context.settings_handler.show_source_client().into() - || parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientTop), - ); - note_options.set( - NoteOptions::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( - AppOptions::SinceOptimize, - parsed_args.is_flag_set(ColumnsFlag::SinceOptimize), - ); + + let note_options = get_note_options(parsed_args, settings); let jobs = JobsCache::default(); @@ -601,6 +577,39 @@ impl Damus { } } +fn get_note_options(args: ColumnsArgs, settings_handler: &&mut SettingsHandler) -> NoteOptions { + let mut note_options = NoteOptions::default(); + + note_options.set( + NoteOptions::Textmode, + args.is_flag_set(ColumnsFlag::Textmode), + ); + note_options.set( + NoteOptions::ScrambleText, + args.is_flag_set(ColumnsFlag::Scramble), + ); + note_options.set( + NoteOptions::HideMedia, + args.is_flag_set(ColumnsFlag::NoMedia), + ); + note_options.set( + NoteOptions::ShowNoteClientTop, + ShowSourceClientOption::Top == settings_handler.show_source_client().into() + || args.is_flag_set(ColumnsFlag::ShowNoteClientTop), + ); + note_options.set( + NoteOptions::ShowNoteClientBottom, + ShowSourceClientOption::Bottom == settings_handler.show_source_client().into() + || args.is_flag_set(ColumnsFlag::ShowNoteClientBottom), + ); + + note_options.set( + NoteOptions::RepliesNewestFirst, + settings_handler.show_replies_newest_first(), + ); + note_options +} + /* fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { let stroke = ui.style().interact(&response).fg_stroke; @@ -622,6 +631,7 @@ fn render_damus_mobile( let mut app_action: Option<AppAction> = None; let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; + if !app.columns(app_ctx.accounts).columns().is_empty() { let r = nav::render_nav( active_col, diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -30,8 +30,6 @@ 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}; @@ -487,13 +485,9 @@ fn process_render_nav_action( .process_relay_action(ui.ctx(), ctx.pool, action); None } - RenderNavAction::SettingsAction(action) => action.process_settings_action( - app, - ctx.settings_handler, - ctx.i18n, - ctx.img_cache, - ui.ctx(), - ), + RenderNavAction::SettingsAction(action) => { + action.process_settings_action(app, ctx.settings, ctx.i18n, ctx.img_cache, ui.ctx()) + } }; if let Some(action) = router_action { @@ -587,28 +581,14 @@ fn render_nav_body( .ui(ui) .map(RenderNavAction::RelayAction), - Route::Settings => { - let mut show_note_client: ShowNoteClientOption = app.note_options.into(); - - let mut theme: String = (if ui.visuals().dark_mode { - "Dark" - } else { - "Light" - }) - .into(); - - let mut selected_language: String = ctx.i18n.get_current_locale().to_string(); - - SettingsView::new( - ctx.img_cache, - &mut selected_language, - &mut theme, - &mut show_note_client, - ctx.i18n, - ) - .ui(ui) - .map(RenderNavAction::SettingsAction) - } + Route::Settings => SettingsView::new( + &mut ctx.settings.get_settings_mut(), + &mut note_context, + &mut app.note_options, + &mut app.jobs, + ) + .ui(ui) + .map(RenderNavAction::SettingsAction), Route::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { txn diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -26,7 +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 settings::ShowSourceClientOption; 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,40 +1,59 @@ -use egui::{vec2, Button, Color32, ComboBox, Frame, Margin, RichText, ThemePreference}; -use notedeck::{tr, Images, LanguageIdentifier, Localization, NotedeckTextStyle, SettingsHandler}; -use notedeck_ui::NoteOptions; +use egui::{ + vec2, Button, Color32, ComboBox, FontId, Frame, Margin, RichText, ScrollArea, ThemePreference, +}; +use enostr::NoteId; +use nostrdb::Transaction; +use notedeck::{ + tr, + ui::{is_narrow, richtext_small}, + Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings, + SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE, +}; +use notedeck_ui::{NoteOptions, NoteView}; use strum::Display; use crate::{nav::RouterAction, Damus, Route}; +const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; + +const THEME_LIGHT: &str = "Light"; +const THEME_DARK: &str = "Dark"; + +const MIN_ZOOM: f32 = 0.5; +const MAX_ZOOM: f32 = 3.0; +const ZOOM_STEP: f32 = 0.1; +const RESET_ZOOM: f32 = 1.0; + #[derive(Clone, Copy, PartialEq, Eq, Display)] -pub enum ShowNoteClientOption { +pub enum ShowSourceClientOption { 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 Into<String> for ShowSourceClientOption { + fn into(self) -> String { + match self { + Self::Hide => "hide".to_string(), + Self::Top => "top".to_string(), + Self::Bottom => "bottom".to_string(), } } } -impl From<NoteOptions> for ShowNoteClientOption { +impl From<NoteOptions> for ShowSourceClientOption { fn from(note_options: NoteOptions) -> Self { if note_options.contains(NoteOptions::ShowNoteClientTop) { - ShowNoteClientOption::Top + ShowSourceClientOption::Top } else if note_options.contains(NoteOptions::ShowNoteClientBottom) { - ShowNoteClientOption::Bottom + ShowSourceClientOption::Bottom } else { - ShowNoteClientOption::Hide + ShowSourceClientOption::Hide } } } -impl From<String> for ShowNoteClientOption { +impl From<String> for ShowSourceClientOption { fn from(s: String) -> Self { match s.to_lowercase().as_str() { "hide" => Self::Hide, @@ -45,7 +64,7 @@ impl From<String> for ShowNoteClientOption { } } -impl ShowNoteClientOption { +impl ShowSourceClientOption { pub fn set_note_options(self, note_options: &mut NoteOptions) { match self { Self::Hide => { @@ -62,13 +81,23 @@ impl ShowNoteClientOption { } } } + + fn label<'a>(&self, i18n: &'a mut Localization) -> String { + match self { + Self::Hide => tr!(i18n, "Hide", "Option in settings section to hide the source client label in note display"), + Self::Top => tr!(i18n, "Top", "Option in settings section to show the source client label at the top of the note"), + Self::Bottom => tr!(i18n, "Bottom", "Option in settings section to show the source client label at the bottom of the note"), + } + } } pub enum SettingsAction { SetZoomFactor(f32), SetTheme(ThemePreference), - SetShowSourceClient(ShowNoteClientOption), + SetShowSourceClient(ShowSourceClientOption), SetLocale(LanguageIdentifier), + SetRepliestNewestFirst(bool), + SetNoteBodyFontSize(f32), OpenRelays, OpenCacheFolder, ClearCacheFolder, @@ -78,7 +107,7 @@ impl SettingsAction { pub fn process_settings_action<'a>( self, app: &mut Damus, - settings_handler: &'a mut SettingsHandler, + settings: &'a mut SettingsHandler, i18n: &'a mut Localization, img_cache: &mut Images, ctx: &egui::Context, @@ -86,423 +115,511 @@ impl SettingsAction { let mut route_action: Option<RouterAction> = None; match self { - SettingsAction::OpenRelays => { + Self::OpenRelays => { route_action = Some(RouterAction::route_to(Route::Relays)); } - SettingsAction::SetZoomFactor(zoom_factor) => { + Self::SetZoomFactor(zoom_factor) => { ctx.set_zoom_factor(zoom_factor); - settings_handler.set_zoom_factor(zoom_factor); + settings.set_zoom_factor(zoom_factor); } - SettingsAction::SetShowSourceClient(option) => { + Self::SetShowSourceClient(option) => { option.set_note_options(&mut app.note_options); - settings_handler.set_show_source_client(option); + settings.set_show_source_client(option); } - SettingsAction::SetTheme(theme) => { - ctx.options_mut(|o| { - o.theme_preference = theme; - }); - settings_handler.set_theme(theme); + Self::SetTheme(theme) => { + ctx.set_theme(theme); + settings.set_theme(theme); } - SettingsAction::SetLocale(language) => { + Self::SetLocale(language) => { if i18n.set_locale(language.clone()).is_ok() { - settings_handler.set_locale(language.to_string()); + settings.set_locale(language.to_string()); } } - SettingsAction::OpenCacheFolder => { + Self::SetRepliestNewestFirst(value) => { + app.note_options.set(NoteOptions::RepliesNewestFirst, value); + settings.set_show_replies_newest_first(value); + } + Self::OpenCacheFolder => { use opener; let _ = opener::open(img_cache.base_path.clone()); } - SettingsAction::ClearCacheFolder => { + Self::ClearCacheFolder => { let _ = img_cache.clear_folder_contents(); } + Self::SetNoteBodyFontSize(size) => { + let mut style = (*ctx.style()).clone(); + style.text_styles.insert( + NotedeckTextStyle::NoteBody.text_style(), + FontId::proportional(size), + ); + ctx.set_style(style); + + settings.set_note_body_font_size(size); + } } - settings_handler.save(); route_action } } pub struct SettingsView<'a> { - theme: &'a mut String, - selected_language: &'a mut String, - show_note_client: &'a mut ShowNoteClientOption, - i18n: &'a mut Localization, - img_cache: &'a mut Images, + settings: &'a mut Settings, + note_context: &'a mut NoteContext<'a>, + note_options: &'a mut NoteOptions, + jobs: &'a mut JobsCache, +} + +fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui)) +where + S: Into<String>, +{ + Frame::group(ui.style()) + .fill(ui.style().visuals.widgets.open.bg_fill) + .inner_margin(10.0) + .show(ui, |ui| { + ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())); + ui.separator(); + + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + + contents(ui) + }); + }); } impl<'a> SettingsView<'a> { pub fn new( - img_cache: &'a mut Images, - selected_language: &'a mut String, - theme: &'a mut String, - show_note_client: &'a mut ShowNoteClientOption, - i18n: &'a mut Localization, + settings: &'a mut Settings, + note_context: &'a mut NoteContext<'a>, + note_options: &'a mut NoteOptions, + jobs: &'a mut JobsCache, ) -> Self { Self { - show_note_client, - theme, - img_cache, - selected_language, - i18n, + settings, + note_context, + note_options, + jobs, } } /// Get the localized name for a language identifier fn get_selected_language_name(&mut self) -> String { - if let Ok(lang_id) = self.selected_language.parse::<LanguageIdentifier>() { - self.i18n + if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() { + self.note_context + .i18n .get_locale_native_name(&lang_id) .map(|s| s.to_owned()) .unwrap_or_else(|| lang_id.to_string()) } else { - self.selected_language.clone() + self.settings.locale.clone() } } - /// Get the localized label for ShowNoteClientOption - fn get_show_note_client_label(&mut self, option: ShowNoteClientOption) -> String { - match option { - ShowNoteClientOption::Hide => tr!( - self.i18n, - "Hide", - "Option in settings section to hide the source client label in note display" - ), - ShowNoteClientOption::Top => tr!( - self.i18n, - "Top", - "Option in settings section to show the source client label at the top of the note" - ), - ShowNoteClientOption::Bottom => tr!( - self.i18n, - "Bottom", - "Option in settings section to show the source client label at the bottom of the note" - ), - }.to_string() - } - - pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { - let id = ui.id(); + pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { let mut action = None; + let title = tr!( + self.note_context.i18n, + "Appearance", + "Label for appearance settings section", + ); + settings_group(ui, title, |ui| { + ui.horizontal(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Font size:", + "Label for font size, Appearance settings section", + ))); - Frame::default() - .inner_margin(Margin::symmetric(10, 10)) - .show(ui, |ui| { - Frame::group(ui.style()) - .fill(ui.style().visuals.widgets.open.bg_fill) - .inner_margin(10.0) - .show(ui, |ui| { - ui.vertical(|ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Appearance", - "Label for appearance settings section" - )) - .text_style(NotedeckTextStyle::Body.text_style()), - ); - ui.separator(); - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - - let current_zoom = ui.ctx().zoom_factor(); - - ui.horizontal(|ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Zoom Level:", - "Label for zoom level, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - - if ui - .button( - RichText::new("-") - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .clicked() - { - let new_zoom = (current_zoom - 0.1).max(0.1); - action = Some(SettingsAction::SetZoomFactor(new_zoom)); - }; - - ui.label( - RichText::new(format!("{:.0}%", current_zoom * 100.0)) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - - if ui - .button( - RichText::new("+") - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .clicked() - { - let new_zoom = (current_zoom + 0.1).min(10.0); - action = Some(SettingsAction::SetZoomFactor(new_zoom)); - }; - - if ui - .button( - RichText::new(tr!( - self.i18n, - "Reset", - "Label for reset zoom level, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .clicked() - { - action = Some(SettingsAction::SetZoomFactor(1.0)); - } - }); - - ui.horizontal(|ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Language:", - "Label for language, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - ComboBox::from_label("") - .selected_text(self.get_selected_language_name()) - .show_ui(ui, |ui| { - for lang in self.i18n.get_available_locales() { - let name = self.i18n - .get_locale_native_name(lang) - .map(|s| s.to_owned()) - .unwrap_or_else(|| lang.to_string()); - if ui - .selectable_value( - self.selected_language, - lang.to_string(), - name, - ) - .clicked() - { - action = Some(SettingsAction::SetLocale(lang.to_owned())) - } - } - }) - }); - - ui.horizontal(|ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Theme:", - "Label for theme, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - if ui - .selectable_value( - self.theme, - "Light".into(), - RichText::new(tr!( - self.i18n, - "Light", - "Label for Theme Light, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .clicked() - { - action = Some(SettingsAction::SetTheme(ThemePreference::Light)); - } - if ui - .selectable_value( - self.theme, - "Dark".into(), - RichText::new(tr!( - self.i18n, - "Dark", - "Label for Theme Dark, Appearance settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .clicked() - { - action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); - } - }); - }); - }); + if ui + .add( + egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0) + .text(""), + ) + .changed() + { + action = Some(SettingsAction::SetNoteBodyFontSize( + self.settings.note_body_font_size, + )); + }; - ui.add_space(5.0); - - Frame::group(ui.style()) - .fill(ui.style().visuals.widgets.open.bg_fill) - .inner_margin(10.0) - .show(ui, |ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Storage", - "Label for storage settings section" - )) - .text_style(NotedeckTextStyle::Body.text_style()), - ); - ui.separator(); - - ui.vertical(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - - ui.horizontal_wrapped(|ui| { - let static_imgs_size = self - .img_cache - .static_imgs - .cache_size - .lock() - .unwrap(); - - let gifs_size = self.img_cache.gifs.cache_size.lock().unwrap(); - - ui.label( - RichText::new(format!("{} {}", - tr!( - self.i18n, - "Image cache size:", - "Label for Image cache size, Storage settings section" - ), - format_size( - [static_imgs_size, gifs_size] - .iter() - .fold(0_u64, |acc, cur| acc - + cur.unwrap_or_default()) - ) - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - - 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")) - .text_style(NotedeckTextStyle::Small.text_style())).clicked() { - action = Some(SettingsAction::OpenCacheFolder); - } - - let clearcache_resp = ui.button( - RichText::new(tr!( - self.i18n, - "Clear cache", - "Label for clear cache button, Storage settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()) - .color(Color32::LIGHT_RED), - ); - - let id_clearcache = id.with("clear_cache"); - if clearcache_resp.clicked() { - ui.data_mut(|d| d.insert_temp(id_clearcache, true)); - } - - if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) { - let mut confirm_pressed = false; - clearcache_resp.show_tooltip_ui(|ui| { - let confirm_resp = ui.button(tr!( - self.i18n, - "Confirm", - "Label for confirm clear cache, Storage settings section" - )); - if confirm_resp.clicked() { - confirm_pressed = true; - } - - if confirm_resp.clicked() || ui.button(tr!( - self.i18n, - "Cancel", - "Label for cancel clear cache, Storage settings section" - )).clicked() { - ui.data_mut(|d| d.insert_temp(id_clearcache, false)); - } - }); - - if confirm_pressed { - action = Some(SettingsAction::ClearCacheFolder); - } else if !confirm_pressed - && clearcache_resp.clicked_elsewhere() - { - ui.data_mut(|d| d.insert_temp(id_clearcache, false)); - } - }; - }); - }); - }); + if ui + .button(richtext_small(tr!( + self.note_context.i18n, + "Reset", + "Label for reset note body font size, Appearance settings section", + ))) + .clicked() + { + action = Some(SettingsAction::SetNoteBodyFontSize( + DEFAULT_NOTE_BODY_FONT_SIZE, + )); + } + }); - ui.add_space(5.0); - - Frame::group(ui.style()) - .fill(ui.style().visuals.widgets.open.bg_fill) - .inner_margin(10.0) - .show(ui, |ui| { - ui.label( - RichText::new(tr!( - self.i18n, - "Others", - "Label for others settings section" - )) - .text_style(NotedeckTextStyle::Body.text_style()), - ); - ui.separator(); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - - ui.horizontal_wrapped(|ui| { - ui.label( - RichText::new( - tr!( - self.i18n, - "Show source client", - "Label for Show source client, others settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), - ); - - for option in [ - ShowNoteClientOption::Hide, - ShowNoteClientOption::Top, - ShowNoteClientOption::Bottom, - ] { - let label = self.get_show_note_client_label(option); - - if ui - .selectable_value( - self.show_note_client, - option, - RichText::new(label) - .text_style(NotedeckTextStyle::Small.text_style()), - ) - .changed() - { - action = Some(SettingsAction::SetShowSourceClient(option)); - } - } - }); - }); + let txn = Transaction::new(self.note_context.ndb).unwrap(); + if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) { + if let Ok(preview_note) = + self.note_context.ndb.get_note_by_id(&txn, &note_id.bytes()) + { + notedeck_ui::padding(8.0, ui, |ui| { + if is_narrow(ui.ctx()) { + ui.set_max_width(ui.available_width()); + } + + NoteView::new( + self.note_context, + &preview_note, + self.note_options.clone(), + self.jobs, + ) + .actionbar(false) + .options_button(false) + .show(ui); }); + ui.separator(); + } + } + + let current_zoom = ui.ctx().zoom_factor(); - ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Zoom Level:", + "Label for zoom level, Appearance settings section", + ))); + + let min_reached = current_zoom <= MIN_ZOOM; + let max_reached = current_zoom >= MAX_ZOOM; if ui - .add_sized( - [ui.available_width(), 30.0], + .add_enabled( + !min_reached, Button::new( - RichText::new(tr!( - self.i18n, - "Configure relays", - "Label for configure relays, settings section" - )) - .text_style(NotedeckTextStyle::Small.text_style()), + RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()), + ), + ) + .clicked() + { + let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM); + action = Some(SettingsAction::SetZoomFactor(new_zoom)); + }; + + ui.label( + RichText::new(format!("{:.0}%", current_zoom * 100.0)) + .text_style(NotedeckTextStyle::Small.text_style()), + ); + + if ui + .add_enabled( + !max_reached, + Button::new( + RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()), ), ) .clicked() { - action = Some(SettingsAction::OpenRelays); + let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM); + action = Some(SettingsAction::SetZoomFactor(new_zoom)); + }; + + if ui + .button(richtext_small(tr!( + self.note_context.i18n, + "Reset", + "Label for reset zoom level, Appearance settings section", + ))) + .clicked() + { + action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM)); + } + }); + + ui.horizontal(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Language:", + "Label for language, Appearance settings section", + ))); + + // + ComboBox::from_label("") + .selected_text(self.get_selected_language_name()) + .show_ui(ui, |ui| { + for lang in self.note_context.i18n.get_available_locales() { + let name = self + .note_context + .i18n + .get_locale_native_name(lang) + .map(|s| s.to_owned()) + .unwrap_or_else(|| lang.to_string()); + if ui + .selectable_value(&mut self.settings.locale, lang.to_string(), name) + .clicked() + { + action = Some(SettingsAction::SetLocale(lang.to_owned())) + } + } + }); + }); + + ui.horizontal(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Theme:", + "Label for theme, Appearance settings section", + ))); + + if ui + .selectable_value( + &mut self.settings.theme, + ThemePreference::Light, + richtext_small(tr!( + self.note_context.i18n, + THEME_LIGHT, + "Label for Theme Light, Appearance settings section", + )), + ) + .clicked() + { + action = Some(SettingsAction::SetTheme(ThemePreference::Light)); } + + if ui + .selectable_value( + &mut self.settings.theme, + ThemePreference::Dark, + richtext_small(tr!( + self.note_context.i18n, + THEME_DARK, + "Label for Theme Dark, Appearance settings section", + )), + ) + .clicked() + { + action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); + } + }); + }); + + action + } + + pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let id = ui.id(); + let mut action: Option<SettingsAction> = None; + let title = tr!( + self.note_context.i18n, + "Storage", + "Label for storage settings section" + ); + settings_group(ui, title, |ui| { + ui.horizontal_wrapped(|ui| { + let static_imgs_size = self + .note_context + .img_cache + .static_imgs + .cache_size + .lock() + .unwrap(); + + let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap(); + + ui.label( + RichText::new(format!( + "{} {}", + tr!( + self.note_context.i18n, + "Image cache size:", + "Label for Image cache size, Storage settings section" + ), + format_size( + [static_imgs_size, gifs_size] + .iter() + .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default()) + ) + )) + .text_style(NotedeckTextStyle::Small.text_style()), + ); + + ui.end_row(); + + if !notedeck::ui::is_compiled_as_mobile() + && ui + .button(richtext_small(tr!( + self.note_context.i18n, + "View folder", + "Label for view folder button, Storage settings section", + ))) + .clicked() + { + action = Some(SettingsAction::OpenCacheFolder); + } + + let clearcache_resp = ui.button( + richtext_small(tr!( + self.note_context.i18n, + "Clear cache", + "Label for clear cache button, Storage settings section", + )) + .color(Color32::LIGHT_RED), + ); + + let id_clearcache = id.with("clear_cache"); + if clearcache_resp.clicked() { + ui.data_mut(|d| d.insert_temp(id_clearcache, true)); + } + + if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) { + let mut confirm_pressed = false; + clearcache_resp.show_tooltip_ui(|ui| { + let confirm_resp = ui.button(tr!( + self.note_context.i18n, + "Confirm", + "Label for confirm clear cache, Storage settings section" + )); + if confirm_resp.clicked() { + confirm_pressed = true; + } + + if confirm_resp.clicked() + || ui + .button(tr!( + self.note_context.i18n, + "Cancel", + "Label for cancel clear cache, Storage settings section" + )) + .clicked() + { + ui.data_mut(|d| d.insert_temp(id_clearcache, false)); + } + }); + + if confirm_pressed { + action = Some(SettingsAction::ClearCacheFolder); + } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() { + ui.data_mut(|d| d.insert_temp(id_clearcache, false)); + } + }; + }); + }); + + action + } + + fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let mut action = None; + + let title = tr!( + self.note_context.i18n, + "Others", + "Label for others settings section" + ); + settings_group(ui, title, |ui| { + ui.horizontal(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Sort replies newest first", + "Label for Sort replies newest first, others settings section", + ))); + + if ui + .toggle_value( + &mut self.settings.show_replies_newest_first, + RichText::new(tr!(self.note_context.i18n, "ON", "ON")) + .text_style(NotedeckTextStyle::Small.text_style()), + ) + .changed() + { + action = Some(SettingsAction::SetRepliestNewestFirst( + self.settings.show_replies_newest_first, + )); + } + }); + + ui.horizontal_wrapped(|ui| { + ui.label(richtext_small(tr!( + self.note_context.i18n, + "Source client", + "Label for Source client, others settings section", + ))); + + for option in [ + ShowSourceClientOption::Hide, + ShowSourceClientOption::Top, + ShowSourceClientOption::Bottom, + ] { + let mut current: ShowSourceClientOption = + self.settings.show_source_client.clone().into(); + + if ui + .selectable_value( + &mut current, + option, + RichText::new(option.label(self.note_context.i18n)) + .text_style(NotedeckTextStyle::Small.text_style()), + ) + .changed() + { + action = Some(SettingsAction::SetShowSourceClient(option)); + } + } + }); + }); + + action + } + + fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let mut action = None; + + if ui + .add_sized( + [ui.available_width(), 30.0], + Button::new(richtext_small(tr!( + self.note_context.i18n, + "Configure relays", + "Label for configure relays, settings section", + ))), + ) + .clicked() + { + action = Some(SettingsAction::OpenRelays); + } + + action + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let mut action: Option<SettingsAction> = None; + + Frame::default() + .inner_margin(Margin::symmetric(10, 10)) + .show(ui, |ui| { + ScrollArea::vertical().show(ui, |ui| { + if let Some(new_action) = self.appearance_section(ui) { + action = Some(new_action); + } + + ui.add_space(5.0); + + if let Some(new_action) = self.storage_section(ui) { + action = Some(new_action); + } + + ui.add_space(5.0); + + if let Some(new_action) = self.other_options_section(ui) { + action = Some(new_action); + } + + ui.add_space(10.0); + + if let Some(new_action) = self.manage_relays_section(ui) { + action = Some(new_action); + } + }); }); action diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs @@ -115,7 +115,10 @@ impl<'a, 'd> ThreadView<'a, 'd> { .unwrap() .list; - let notes = note_builder.into_notes(&mut self.threads.seen_flags); + let notes = note_builder.into_notes( + self.note_options.contains(NoteOptions::RepliesNewestFirst), + &mut self.threads.seen_flags, + ); if !full_chain { // TODO(kernelkind): insert UI denoting we don't have the full chain yet @@ -223,7 +226,11 @@ impl<'a> ThreadNoteBuilder<'a> { self.replies.push(note); } - pub fn into_notes(mut self, seen_flags: &mut NoteSeenFlags) -> ThreadNotes<'a> { + pub fn into_notes( + mut self, + replies_newer_first: bool, + seen_flags: &mut NoteSeenFlags, + ) -> ThreadNotes<'a> { let mut notes = Vec::new(); let selected_is_root = self.chain.is_empty(); @@ -246,6 +253,11 @@ impl<'a> ThreadNoteBuilder<'a> { unread_and_have_replies: false, }); + if replies_newer_first { + self.replies + .sort_by(|a, b| b.created_at().cmp(&a.created_at())); + } + for reply in self.replies { notes.push(ThreadNote { unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false), diff --git a/crates/notedeck_ui/src/mention.rs b/crates/notedeck_ui/src/mention.rs @@ -2,7 +2,7 @@ use crate::ProfilePreview; use egui::Sense; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{name::get_display_name, Images, NoteAction}; +use notedeck::{name::get_display_name, Images, NoteAction, NotedeckTextStyle}; pub struct Mention<'a> { ndb: &'a Ndb, @@ -75,7 +75,9 @@ fn mention_ui( get_display_name(profile.as_ref()).username_or_displayname() ); - let mut text = egui::RichText::new(name).color(link_color); + let mut text = egui::RichText::new(name) + .color(link_color) + .text_style(NotedeckTextStyle::NoteBody.text_style()); if let Some(size) = size { text = text.size(size); } diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -4,12 +4,12 @@ use crate::{ }; use notedeck::{JobsCache, RenderableMedia}; -use egui::{Color32, Hyperlink, RichText}; +use egui::{vec2, Color32, Hyperlink, Label, RichText}; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; use tracing::warn; use super::media::image_carousel; -use notedeck::{update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext}; +use notedeck::{update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle}; pub struct NoteContents<'a, 'd> { note_context: &'a mut NoteContext<'d>, @@ -42,6 +42,8 @@ impl<'a, 'd> NoteContents<'a, 'd> { impl egui::Widget for &mut NoteContents<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); + if self.options.contains(NoteOptions::ShowNoteClientTop) { render_client(ui, self.note_context.note_cache, self.note); } @@ -158,9 +160,9 @@ pub fn render_note_contents<'a>( return; }; - ui.spacing_mut().item_spacing.x = 0.0; + ui.spacing_mut().item_spacing = vec2(0.0, 0.0); - for block in blocks.iter(note) { + 'block_loop: for block in blocks.iter(note) { match block.blocktype() { BlockType::MentionBech32 => match block.as_mention().unwrap() { Mention::Profile(profile) => { @@ -200,13 +202,24 @@ pub fn render_note_contents<'a>( } _ => { - ui.colored_label(link_color, format!("@{}", &block.as_str()[..16])); + ui.colored_label( + link_color, + RichText::new(format!("@{}", &block.as_str()[..16])) + .text_style(NotedeckTextStyle::NoteBody.text_style()), + ); } }, BlockType::Hashtag => { + if block.as_str().trim().len() == 0 { + continue 'block_loop; + } let resp = ui - .colored_label(link_color, format!("#{}", block.as_str())) + .colored_label( + link_color, + RichText::new(format!("#{}", block.as_str())) + .text_style(NotedeckTextStyle::NoteBody.text_style()), + ) .on_hover_cursor(egui::CursorIcon::PointingHand); if resp.clicked() { @@ -231,8 +244,13 @@ pub fn render_note_contents<'a>( }; if hide_media || !found_supported() { + if block.as_str().trim().len() == 0 { + continue 'block_loop; + } ui.add(Hyperlink::from_label_and_url( - RichText::new(block.as_str()).color(link_color), + RichText::new(block.as_str()) + .color(link_color) + .text_style(NotedeckTextStyle::NoteBody.text_style()), block.as_str(), )); } @@ -258,17 +276,28 @@ pub fn render_note_contents<'a>( current_len += block_str.len(); block_str }; - + if block_str.trim().len() == 0 { + continue 'block_loop; + } if options.contains(NoteOptions::ScrambleText) { ui.add( - egui::Label::new(rot13(block_str)) - .wrap() - .selectable(selectable), + Label::new( + RichText::new(rot13(block_str)) + .text_style(NotedeckTextStyle::NoteBody.text_style()), + ) + .wrap() + .selectable(selectable), ); } else { - ui.add(egui::Label::new(block_str).wrap().selectable(selectable)); + ui.add( + Label::new( + RichText::new(block_str) + .text_style(NotedeckTextStyle::NoteBody.text_style()), + ) + .wrap() + .selectable(selectable), + ); } - // don't render any more blocks if truncate { break; diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -50,67 +50,73 @@ pub fn image_carousel( .drag_to_scroll(false) .id_salt(carousel_id) .show(ui, |ui| { - ui.horizontal(|ui| { - let mut media_infos: Vec<MediaInfo> = Vec::with_capacity(medias.len()); - let mut media_action: Option<(usize, MediaUIAction)> = None; - - for (i, media) in medias.iter().enumerate() { - let RenderableMedia { - url, - media_type, - obfuscation_type: blur_type, - } = media; - - let cache = match media_type { - MediaCacheType::Image => &mut img_cache.static_imgs, - MediaCacheType::Gif => &mut img_cache.gifs, - }; - let media_state = get_content_media_render_state( - ui, - job_pool, - jobs, - trusted_media, - size, - &mut cache.textures_cache, - url, - *media_type, - &cache.cache_dir, - blur_type, - ); - - let media_response = render_media( - ui, - &mut img_cache.gif_states, - media_state, - url, - size, - i18n, - note_options.contains(NoteOptions::Wide), - ); - - if let Some(action) = media_response.inner { - media_action = Some((i, action)) + let response = ui + .horizontal(|ui| { + let spacing = ui.spacing_mut(); + spacing.item_spacing.x = 8.0; + + let mut media_infos: Vec<MediaInfo> = Vec::with_capacity(medias.len()); + let mut media_action: Option<(usize, MediaUIAction)> = None; + + for (i, media) in medias.iter().enumerate() { + let RenderableMedia { + url, + media_type, + obfuscation_type: blur_type, + } = media; + + let cache = match media_type { + MediaCacheType::Image => &mut img_cache.static_imgs, + MediaCacheType::Gif => &mut img_cache.gifs, + }; + let media_state = get_content_media_render_state( + ui, + job_pool, + jobs, + trusted_media, + size, + &mut cache.textures_cache, + url, + *media_type, + &cache.cache_dir, + blur_type, + ); + + let media_response = render_media( + ui, + &mut img_cache.gif_states, + media_state, + url, + size, + i18n, + note_options.contains(NoteOptions::Wide), + ); + + if let Some(action) = media_response.inner { + media_action = Some((i, action)) + } + + let rect = media_response.response.rect; + media_infos.push(MediaInfo { + url: url.clone(), + original_position: rect, + }) } - let rect = media_response.response.rect; - media_infos.push(MediaInfo { - url: url.clone(), - original_position: rect, - }) - } - - if let Some((i, media_action)) = media_action { - action = media_action.into_media_action( - ui.ctx(), - medias, - media_infos, - i, - img_cache, - ImageType::Content(Some((size.x as u32, size.y as u32))), - ); - } - }) - .response + if let Some((i, media_action)) = media_action { + action = media_action.into_media_action( + ui.ctx(), + medias, + media_infos, + i, + img_cache, + ImageType::Content(Some((size.x as u32, size.y as u32))), + ); + } + }) + .response; + ui.add_space(8.0); + response }) .inner }); diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -344,7 +344,12 @@ impl<'a, 'd> NoteView<'a, 'd> { 1.0, ui.visuals().noninteractive().bg_stroke.color, )) - .show(ui, |ui| self.show_impl(ui)) + .show(ui, |ui| { + if is_narrow(ui.ctx()) { + ui.set_width(ui.available_width()); + } + self.show_impl(ui) + }) .inner } else { self.show_impl(ui) diff --git a/crates/notedeck_ui/src/note/options.rs b/crates/notedeck_ui/src/note/options.rs @@ -25,6 +25,8 @@ bitflags! { /// Show note's client in the note header const ShowNoteClientTop = 1 << 12; const ShowNoteClientBottom = 1 << 13; + + const RepliesNewestFirst = 1 << 14; } }