notedeck

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

settings.rs (22789B)


      1 use egui::{
      2     vec2, Button, Color32, ComboBox, FontId, Frame, Margin, RichText, ScrollArea, ThemePreference,
      3 };
      4 use enostr::NoteId;
      5 use nostrdb::Transaction;
      6 use notedeck::{
      7     tr,
      8     ui::{is_narrow, richtext_small},
      9     Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings,
     10     SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE,
     11 };
     12 use notedeck_ui::{NoteOptions, NoteView};
     13 use strum::Display;
     14 
     15 use crate::{nav::RouterAction, Damus, Route};
     16 
     17 const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw";
     18 
     19 const MIN_ZOOM: f32 = 0.5;
     20 const MAX_ZOOM: f32 = 3.0;
     21 const ZOOM_STEP: f32 = 0.1;
     22 const RESET_ZOOM: f32 = 1.0;
     23 
     24 #[derive(Clone, Copy, PartialEq, Eq, Display)]
     25 pub enum ShowSourceClientOption {
     26     Hide,
     27     Top,
     28     Bottom,
     29 }
     30 
     31 impl From<ShowSourceClientOption> for String {
     32     fn from(show_option: ShowSourceClientOption) -> Self {
     33         match show_option {
     34             ShowSourceClientOption::Hide => "hide".to_string(),
     35             ShowSourceClientOption::Top => "top".to_string(),
     36             ShowSourceClientOption::Bottom => "bottom".to_string(),
     37         }
     38     }
     39 }
     40 
     41 impl From<NoteOptions> for ShowSourceClientOption {
     42     fn from(note_options: NoteOptions) -> Self {
     43         if note_options.contains(NoteOptions::ClientNameTop) {
     44             ShowSourceClientOption::Top
     45         } else if note_options.contains(NoteOptions::ClientNameBottom) {
     46             ShowSourceClientOption::Bottom
     47         } else {
     48             ShowSourceClientOption::Hide
     49         }
     50     }
     51 }
     52 
     53 impl From<String> for ShowSourceClientOption {
     54     fn from(s: String) -> Self {
     55         match s.to_lowercase().as_str() {
     56             "hide" => Self::Hide,
     57             "top" => Self::Top,
     58             "bottom" => Self::Bottom,
     59             _ => Self::Hide, // default fallback
     60         }
     61     }
     62 }
     63 
     64 impl ShowSourceClientOption {
     65     pub fn set_note_options(self, note_options: &mut NoteOptions) {
     66         match self {
     67             Self::Hide => {
     68                 note_options.set(NoteOptions::ClientNameTop, false);
     69                 note_options.set(NoteOptions::ClientNameBottom, false);
     70             }
     71             Self::Bottom => {
     72                 note_options.set(NoteOptions::ClientNameTop, false);
     73                 note_options.set(NoteOptions::ClientNameBottom, true);
     74             }
     75             Self::Top => {
     76                 note_options.set(NoteOptions::ClientNameTop, true);
     77                 note_options.set(NoteOptions::ClientNameBottom, false);
     78             }
     79         }
     80     }
     81 
     82     fn label(&self, i18n: &mut Localization) -> String {
     83         match self {
     84             Self::Hide => tr!(
     85                 i18n,
     86                 "Hide",
     87                 "Option in settings section to hide the source client label in note display"
     88             ),
     89             Self::Top => tr!(
     90                 i18n,
     91                 "Top",
     92                 "Option in settings section to show the source client label at the top of the note"
     93             ),
     94             Self::Bottom => tr!(
     95                 i18n,
     96                 "Bottom",
     97                 "Option in settings section to show the source client label at the bottom of the note"
     98             ),
     99         }
    100     }
    101 }
    102 
    103 pub enum SettingsAction {
    104     SetZoomFactor(f32),
    105     SetTheme(ThemePreference),
    106     SetShowSourceClient(ShowSourceClientOption),
    107     SetLocale(LanguageIdentifier),
    108     SetRepliestNewestFirst(bool),
    109     SetNoteBodyFontSize(f32),
    110     OpenRelays,
    111     OpenCacheFolder,
    112     ClearCacheFolder,
    113 }
    114 
    115 impl SettingsAction {
    116     pub fn process_settings_action<'a>(
    117         self,
    118         app: &mut Damus,
    119         settings: &'a mut SettingsHandler,
    120         i18n: &'a mut Localization,
    121         img_cache: &mut Images,
    122         ctx: &egui::Context,
    123     ) -> Option<RouterAction> {
    124         let mut route_action: Option<RouterAction> = None;
    125 
    126         match self {
    127             Self::OpenRelays => {
    128                 route_action = Some(RouterAction::route_to(Route::Relays));
    129             }
    130             Self::SetZoomFactor(zoom_factor) => {
    131                 ctx.set_zoom_factor(zoom_factor);
    132                 settings.set_zoom_factor(zoom_factor);
    133             }
    134             Self::SetShowSourceClient(option) => {
    135                 option.set_note_options(&mut app.note_options);
    136 
    137                 settings.set_show_source_client(option);
    138             }
    139             Self::SetTheme(theme) => {
    140                 ctx.set_theme(theme);
    141                 settings.set_theme(theme);
    142             }
    143             Self::SetLocale(language) => {
    144                 if i18n.set_locale(language.clone()).is_ok() {
    145                     settings.set_locale(language.to_string());
    146                 }
    147             }
    148             Self::SetRepliestNewestFirst(value) => {
    149                 app.note_options.set(NoteOptions::RepliesNewestFirst, value);
    150                 settings.set_show_replies_newest_first(value);
    151             }
    152             Self::OpenCacheFolder => {
    153                 use opener;
    154                 let _ = opener::open(img_cache.base_path.clone());
    155             }
    156             Self::ClearCacheFolder => {
    157                 let _ = img_cache.clear_folder_contents();
    158             }
    159             Self::SetNoteBodyFontSize(size) => {
    160                 let mut style = (*ctx.style()).clone();
    161                 style.text_styles.insert(
    162                     NotedeckTextStyle::NoteBody.text_style(),
    163                     FontId::proportional(size),
    164                 );
    165                 ctx.set_style(style);
    166 
    167                 settings.set_note_body_font_size(size);
    168             }
    169         }
    170         route_action
    171     }
    172 }
    173 
    174 pub struct SettingsView<'a> {
    175     settings: &'a mut Settings,
    176     note_context: &'a mut NoteContext<'a>,
    177     note_options: &'a mut NoteOptions,
    178     jobs: &'a mut JobsCache,
    179 }
    180 
    181 fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui))
    182 where
    183     S: Into<String>,
    184 {
    185     Frame::group(ui.style())
    186         .fill(ui.style().visuals.widgets.open.bg_fill)
    187         .inner_margin(10.0)
    188         .show(ui, |ui| {
    189             ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()));
    190             ui.separator();
    191 
    192             ui.vertical(|ui| {
    193                 ui.spacing_mut().item_spacing = vec2(10.0, 10.0);
    194 
    195                 contents(ui)
    196             });
    197         });
    198 }
    199 
    200 impl<'a> SettingsView<'a> {
    201     pub fn new(
    202         settings: &'a mut Settings,
    203         note_context: &'a mut NoteContext<'a>,
    204         note_options: &'a mut NoteOptions,
    205         jobs: &'a mut JobsCache,
    206     ) -> Self {
    207         Self {
    208             settings,
    209             note_context,
    210             note_options,
    211             jobs,
    212         }
    213     }
    214 
    215     /// Get the localized name for a language identifier
    216     fn get_selected_language_name(&mut self) -> String {
    217         if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() {
    218             self.note_context
    219                 .i18n
    220                 .get_locale_native_name(&lang_id)
    221                 .map(|s| s.to_owned())
    222                 .unwrap_or_else(|| lang_id.to_string())
    223         } else {
    224             self.settings.locale.clone()
    225         }
    226     }
    227 
    228     pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    229         let mut action = None;
    230         let title = tr!(
    231             self.note_context.i18n,
    232             "Appearance",
    233             "Label for appearance settings section",
    234         );
    235         settings_group(ui, title, |ui| {
    236             ui.horizontal(|ui| {
    237                 ui.label(richtext_small(tr!(
    238                     self.note_context.i18n,
    239                     "Font size:",
    240                     "Label for font size, Appearance settings section",
    241                 )));
    242 
    243                 if ui
    244                     .add(
    245                         egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0)
    246                             .text(""),
    247                     )
    248                     .changed()
    249                 {
    250                     action = Some(SettingsAction::SetNoteBodyFontSize(
    251                         self.settings.note_body_font_size,
    252                     ));
    253                 };
    254 
    255                 if ui
    256                     .button(richtext_small(tr!(
    257                         self.note_context.i18n,
    258                         "Reset",
    259                         "Label for reset note body font size, Appearance settings section",
    260                     )))
    261                     .clicked()
    262                 {
    263                     action = Some(SettingsAction::SetNoteBodyFontSize(
    264                         DEFAULT_NOTE_BODY_FONT_SIZE,
    265                     ));
    266                 }
    267             });
    268 
    269             let txn = Transaction::new(self.note_context.ndb).unwrap();
    270 
    271             if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) {
    272                 if let Ok(preview_note) =
    273                     self.note_context.ndb.get_note_by_id(&txn, note_id.bytes())
    274                 {
    275                     notedeck_ui::padding(8.0, ui, |ui| {
    276                         if is_narrow(ui.ctx()) {
    277                             ui.set_max_width(ui.available_width());
    278 
    279                             NoteView::new(
    280                                 self.note_context,
    281                                 &preview_note,
    282                                 *self.note_options,
    283                                 self.jobs,
    284                             )
    285                             .actionbar(false)
    286                             .options_button(false)
    287                             .show(ui);
    288                         }
    289                     });
    290                     ui.separator();
    291                 }
    292             }
    293 
    294             let current_zoom = ui.ctx().zoom_factor();
    295 
    296             ui.horizontal(|ui| {
    297                 ui.label(richtext_small(tr!(
    298                     self.note_context.i18n,
    299                     "Zoom Level:",
    300                     "Label for zoom level, Appearance settings section",
    301                 )));
    302 
    303                 let min_reached = current_zoom <= MIN_ZOOM;
    304                 let max_reached = current_zoom >= MAX_ZOOM;
    305 
    306                 if ui
    307                     .add_enabled(
    308                         !min_reached,
    309                         Button::new(
    310                             RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()),
    311                         ),
    312                     )
    313                     .clicked()
    314                 {
    315                     let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM);
    316                     action = Some(SettingsAction::SetZoomFactor(new_zoom));
    317                 };
    318 
    319                 ui.label(
    320                     RichText::new(format!("{:.0}%", current_zoom * 100.0))
    321                         .text_style(NotedeckTextStyle::Small.text_style()),
    322                 );
    323 
    324                 if ui
    325                     .add_enabled(
    326                         !max_reached,
    327                         Button::new(
    328                             RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()),
    329                         ),
    330                     )
    331                     .clicked()
    332                 {
    333                     let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM);
    334                     action = Some(SettingsAction::SetZoomFactor(new_zoom));
    335                 };
    336 
    337                 if ui
    338                     .button(richtext_small(tr!(
    339                         self.note_context.i18n,
    340                         "Reset",
    341                         "Label for reset zoom level, Appearance settings section",
    342                     )))
    343                     .clicked()
    344                 {
    345                     action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM));
    346                 }
    347             });
    348 
    349             ui.horizontal(|ui| {
    350                 ui.label(richtext_small(tr!(
    351                     self.note_context.i18n,
    352                     "Language:",
    353                     "Label for language, Appearance settings section",
    354                 )));
    355 
    356                 //
    357                 ComboBox::from_label("")
    358                     .selected_text(self.get_selected_language_name())
    359                     .show_ui(ui, |ui| {
    360                         for lang in self.note_context.i18n.get_available_locales() {
    361                             let name = self
    362                                 .note_context
    363                                 .i18n
    364                                 .get_locale_native_name(lang)
    365                                 .map(|s| s.to_owned())
    366                                 .unwrap_or_else(|| lang.to_string());
    367                             if ui
    368                                 .selectable_value(&mut self.settings.locale, lang.to_string(), name)
    369                                 .clicked()
    370                             {
    371                                 action = Some(SettingsAction::SetLocale(lang.to_owned()))
    372                             }
    373                         }
    374                     });
    375             });
    376 
    377             ui.horizontal(|ui| {
    378                 ui.label(richtext_small(tr!(
    379                     self.note_context.i18n,
    380                     "Theme:",
    381                     "Label for theme, Appearance settings section",
    382                 )));
    383 
    384                 if ui
    385                     .selectable_value(
    386                         &mut self.settings.theme,
    387                         ThemePreference::Light,
    388                         richtext_small(tr!(
    389                             self.note_context.i18n,
    390                             "Light",
    391                             "Label for Theme Light, Appearance settings section",
    392                         )),
    393                     )
    394                     .clicked()
    395                 {
    396                     action = Some(SettingsAction::SetTheme(ThemePreference::Light));
    397                 }
    398 
    399                 if ui
    400                     .selectable_value(
    401                         &mut self.settings.theme,
    402                         ThemePreference::Dark,
    403                         richtext_small(tr!(
    404                             self.note_context.i18n,
    405                             "Dark",
    406                             "Label for Theme Dark, Appearance settings section",
    407                         )),
    408                     )
    409                     .clicked()
    410                 {
    411                     action = Some(SettingsAction::SetTheme(ThemePreference::Dark));
    412                 }
    413             });
    414         });
    415 
    416         action
    417     }
    418 
    419     pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    420         let id = ui.id();
    421         let mut action: Option<SettingsAction> = None;
    422         let title = tr!(
    423             self.note_context.i18n,
    424             "Storage",
    425             "Label for storage settings section"
    426         );
    427         settings_group(ui, title, |ui| {
    428             ui.horizontal_wrapped(|ui| {
    429                 let static_imgs_size = self
    430                     .note_context
    431                     .img_cache
    432                     .static_imgs
    433                     .cache_size
    434                     .lock()
    435                     .unwrap();
    436 
    437                 let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap();
    438 
    439                 ui.label(
    440                     RichText::new(format!(
    441                         "{} {}",
    442                         tr!(
    443                             self.note_context.i18n,
    444                             "Image cache size:",
    445                             "Label for Image cache size, Storage settings section"
    446                         ),
    447                         format_size(
    448                             [static_imgs_size, gifs_size]
    449                                 .iter()
    450                                 .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default())
    451                         )
    452                     ))
    453                     .text_style(NotedeckTextStyle::Small.text_style()),
    454                 );
    455 
    456                 ui.end_row();
    457 
    458                 if !notedeck::ui::is_compiled_as_mobile()
    459                     && ui
    460                         .button(richtext_small(tr!(
    461                             self.note_context.i18n,
    462                             "View folder",
    463                             "Label for view folder button, Storage settings section",
    464                         )))
    465                         .clicked()
    466                 {
    467                     action = Some(SettingsAction::OpenCacheFolder);
    468                 }
    469 
    470                 let clearcache_resp = ui.button(
    471                     richtext_small(tr!(
    472                         self.note_context.i18n,
    473                         "Clear cache",
    474                         "Label for clear cache button, Storage settings section",
    475                     ))
    476                     .color(Color32::LIGHT_RED),
    477                 );
    478 
    479                 let id_clearcache = id.with("clear_cache");
    480                 if clearcache_resp.clicked() {
    481                     ui.data_mut(|d| d.insert_temp(id_clearcache, true));
    482                 }
    483 
    484                 if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) {
    485                     let mut confirm_pressed = false;
    486                     clearcache_resp.show_tooltip_ui(|ui| {
    487                         let confirm_resp = ui.button(tr!(
    488                             self.note_context.i18n,
    489                             "Confirm",
    490                             "Label for confirm clear cache, Storage settings section"
    491                         ));
    492                         if confirm_resp.clicked() {
    493                             confirm_pressed = true;
    494                         }
    495 
    496                         if confirm_resp.clicked()
    497                             || ui
    498                                 .button(tr!(
    499                                     self.note_context.i18n,
    500                                     "Cancel",
    501                                     "Label for cancel clear cache, Storage settings section"
    502                                 ))
    503                                 .clicked()
    504                         {
    505                             ui.data_mut(|d| d.insert_temp(id_clearcache, false));
    506                         }
    507                     });
    508 
    509                     if confirm_pressed {
    510                         action = Some(SettingsAction::ClearCacheFolder);
    511                     } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() {
    512                         ui.data_mut(|d| d.insert_temp(id_clearcache, false));
    513                     }
    514                 };
    515             });
    516         });
    517 
    518         action
    519     }
    520 
    521     fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    522         let mut action = None;
    523 
    524         let title = tr!(
    525             self.note_context.i18n,
    526             "Others",
    527             "Label for others settings section"
    528         );
    529         settings_group(ui, title, |ui| {
    530             ui.horizontal(|ui| {
    531                 ui.label(richtext_small(tr!(
    532                     self.note_context.i18n,
    533                     "Sort replies newest first:",
    534                     "Label for Sort replies newest first, others settings section",
    535                 )));
    536 
    537                 if ui
    538                     .toggle_value(
    539                         &mut self.settings.show_replies_newest_first,
    540                         RichText::new(tr!(
    541                             self.note_context.i18n,
    542                             "On",
    543                             "Setting to turn on sorting replies so that the newest are shown first"
    544                         ))
    545                         .text_style(NotedeckTextStyle::Small.text_style()),
    546                     )
    547                     .changed()
    548                 {
    549                     action = Some(SettingsAction::SetRepliestNewestFirst(
    550                         self.settings.show_replies_newest_first,
    551                     ));
    552                 }
    553             });
    554 
    555             ui.horizontal_wrapped(|ui| {
    556                 ui.label(richtext_small(tr!(
    557                     self.note_context.i18n,
    558                     "Source client:",
    559                     "Label for Source client, others settings section",
    560                 )));
    561 
    562                 for option in [
    563                     ShowSourceClientOption::Hide,
    564                     ShowSourceClientOption::Top,
    565                     ShowSourceClientOption::Bottom,
    566                 ] {
    567                     let mut current: ShowSourceClientOption =
    568                         self.settings.show_source_client.clone().into();
    569 
    570                     if ui
    571                         .selectable_value(
    572                             &mut current,
    573                             option,
    574                             RichText::new(option.label(self.note_context.i18n))
    575                                 .text_style(NotedeckTextStyle::Small.text_style()),
    576                         )
    577                         .changed()
    578                     {
    579                         action = Some(SettingsAction::SetShowSourceClient(option));
    580                     }
    581                 }
    582             });
    583         });
    584 
    585         action
    586     }
    587 
    588     fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    589         let mut action = None;
    590 
    591         if ui
    592             .add_sized(
    593                 [ui.available_width(), 30.0],
    594                 Button::new(richtext_small(tr!(
    595                     self.note_context.i18n,
    596                     "Configure relays",
    597                     "Label for configure relays, settings section",
    598                 ))),
    599             )
    600             .clicked()
    601         {
    602             action = Some(SettingsAction::OpenRelays);
    603         }
    604 
    605         action
    606     }
    607 
    608     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
    609         let mut action: Option<SettingsAction> = None;
    610 
    611         Frame::default()
    612             .inner_margin(Margin::symmetric(10, 10))
    613             .show(ui, |ui| {
    614                 ScrollArea::vertical().show(ui, |ui| {
    615                     if let Some(new_action) = self.appearance_section(ui) {
    616                         action = Some(new_action);
    617                     }
    618 
    619                     ui.add_space(5.0);
    620 
    621                     if let Some(new_action) = self.storage_section(ui) {
    622                         action = Some(new_action);
    623                     }
    624 
    625                     ui.add_space(5.0);
    626 
    627                     if let Some(new_action) = self.other_options_section(ui) {
    628                         action = Some(new_action);
    629                     }
    630 
    631                     ui.add_space(10.0);
    632 
    633                     if let Some(new_action) = self.manage_relays_section(ui) {
    634                         action = Some(new_action);
    635                     }
    636                 });
    637             });
    638 
    639         action
    640     }
    641 }
    642 
    643 pub fn format_size(size_bytes: u64) -> String {
    644     const KB: f64 = 1024.0;
    645     const MB: f64 = KB * 1024.0;
    646     const GB: f64 = MB * 1024.0;
    647 
    648     let size = size_bytes as f64;
    649 
    650     if size < KB {
    651         format!("{size:.0} Bytes")
    652     } else if size < MB {
    653         format!("{:.1} KB", size / KB)
    654     } else if size < GB {
    655         format!("{:.1} MB", size / MB)
    656     } else {
    657         format!("{:.2} GB", size / GB)
    658     }
    659 }