settings.rs (28576B)
1 use egui::{ 2 vec2, Button, Color32, ComboBox, CornerRadius, FontId, Frame, Layout, Margin, RichText, 3 ScrollArea, TextEdit, ThemePreference, 4 }; 5 use egui_extras::{Size, StripBuilder}; 6 use enostr::NoteId; 7 use nostrdb::Transaction; 8 use notedeck::{ 9 tr, ui::richtext_small, DragResponse, Images, LanguageIdentifier, Localization, NoteContext, 10 NotedeckTextStyle, Settings, SettingsHandler, DEFAULT_MAX_HASHTAGS_PER_NOTE, 11 DEFAULT_NOTE_BODY_FONT_SIZE, 12 }; 13 use notedeck_ui::{ 14 app_images::{copy_to_clipboard_dark_image, copy_to_clipboard_image}, 15 AnimationHelper, NoteOptions, NoteView, 16 }; 17 18 use crate::{nav::RouterAction, ui::account_login_view::eye_button, Damus, Route}; 19 20 const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw"; 21 22 const MIN_ZOOM: f32 = 0.5; 23 const MAX_ZOOM: f32 = 3.0; 24 const ZOOM_STEP: f32 = 0.1; 25 const RESET_ZOOM: f32 = 1.0; 26 27 pub enum SettingsAction { 28 SetZoomFactor(f32), 29 SetTheme(ThemePreference), 30 SetLocale(LanguageIdentifier), 31 SetRepliestNewestFirst(bool), 32 SetNoteBodyFontSize(f32), 33 SetAnimateNavTransitions(bool), 34 SetMaxHashtagsPerNote(usize), 35 OpenRelays, 36 OpenCacheFolder, 37 ClearCacheFolder, 38 } 39 40 impl SettingsAction { 41 pub fn process_settings_action<'a>( 42 self, 43 app: &mut Damus, 44 settings: &'a mut SettingsHandler, 45 i18n: &'a mut Localization, 46 img_cache: &mut Images, 47 ctx: &egui::Context, 48 accounts: &mut notedeck::Accounts, 49 ) -> Option<RouterAction> { 50 let mut route_action: Option<RouterAction> = None; 51 52 match self { 53 Self::OpenRelays => { 54 route_action = Some(RouterAction::route_to(Route::Relays)); 55 } 56 Self::SetZoomFactor(zoom_factor) => { 57 ctx.set_zoom_factor(zoom_factor); 58 settings.set_zoom_factor(zoom_factor); 59 } 60 Self::SetTheme(theme) => { 61 ctx.set_theme(theme); 62 settings.set_theme(theme); 63 } 64 Self::SetLocale(language) => { 65 if i18n.set_locale(language.clone()).is_ok() { 66 settings.set_locale(language.to_string()); 67 } 68 } 69 Self::SetRepliestNewestFirst(value) => { 70 app.note_options.set(NoteOptions::RepliesNewestFirst, value); 71 settings.set_show_replies_newest_first(value); 72 } 73 Self::OpenCacheFolder => { 74 use opener; 75 let _ = opener::open(img_cache.base_path.clone()); 76 } 77 Self::ClearCacheFolder => { 78 let _ = img_cache.clear_folder_contents(); 79 } 80 Self::SetNoteBodyFontSize(size) => { 81 let mut style = (*ctx.style()).clone(); 82 style.text_styles.insert( 83 NotedeckTextStyle::NoteBody.text_style(), 84 FontId::proportional(size), 85 ); 86 ctx.set_style(style); 87 88 settings.set_note_body_font_size(size); 89 } 90 91 Self::SetAnimateNavTransitions(value) => { 92 settings.set_animate_nav_transitions(value); 93 } 94 95 Self::SetMaxHashtagsPerNote(value) => { 96 settings.set_max_hashtags_per_note(value); 97 accounts.update_max_hashtags_per_note(value); 98 } 99 } 100 route_action 101 } 102 } 103 104 pub struct SettingsView<'a> { 105 settings: &'a mut Settings, 106 note_context: &'a mut NoteContext<'a>, 107 note_options: &'a mut NoteOptions, 108 } 109 110 fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui)) 111 where 112 S: Into<String>, 113 { 114 Frame::group(ui.style()) 115 .fill(ui.style().visuals.widgets.open.bg_fill) 116 .inner_margin(10.0) 117 .show(ui, |ui| { 118 ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style())); 119 ui.separator(); 120 121 ui.vertical(|ui| { 122 ui.spacing_mut().item_spacing = vec2(10.0, 10.0); 123 124 contents(ui) 125 }); 126 }); 127 } 128 129 impl<'a> SettingsView<'a> { 130 pub fn new( 131 settings: &'a mut Settings, 132 note_context: &'a mut NoteContext<'a>, 133 note_options: &'a mut NoteOptions, 134 ) -> Self { 135 Self { 136 settings, 137 note_context, 138 note_options, 139 } 140 } 141 142 /// Get the localized name for a language identifier 143 fn get_selected_language_name(&mut self) -> String { 144 if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() { 145 self.note_context 146 .i18n 147 .get_locale_native_name(&lang_id) 148 .map(|s| s.to_owned()) 149 .unwrap_or_else(|| lang_id.to_string()) 150 } else { 151 self.settings.locale.clone() 152 } 153 } 154 155 pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 156 let mut action = None; 157 let title = tr!( 158 self.note_context.i18n, 159 "Appearance", 160 "Label for appearance settings section", 161 ); 162 settings_group(ui, title, |ui| { 163 ui.horizontal_wrapped(|ui| { 164 ui.label(richtext_small(tr!( 165 self.note_context.i18n, 166 "Font size:", 167 "Label for font size, Appearance settings section", 168 ))); 169 170 if ui 171 .add( 172 egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0) 173 .text(""), 174 ) 175 .changed() 176 { 177 action = Some(SettingsAction::SetNoteBodyFontSize( 178 self.settings.note_body_font_size, 179 )); 180 }; 181 182 if ui 183 .button(richtext_small(tr!( 184 self.note_context.i18n, 185 "Reset", 186 "Label for reset note body font size, Appearance settings section", 187 ))) 188 .clicked() 189 { 190 action = Some(SettingsAction::SetNoteBodyFontSize( 191 DEFAULT_NOTE_BODY_FONT_SIZE, 192 )); 193 } 194 }); 195 196 let txn = Transaction::new(self.note_context.ndb).unwrap(); 197 198 if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) { 199 if let Ok(preview_note) = 200 self.note_context.ndb.get_note_by_id(&txn, note_id.bytes()) 201 { 202 notedeck_ui::padding(8.0, ui, |ui| { 203 if notedeck::ui::is_narrow(ui.ctx()) { 204 ui.set_max_width(ui.available_width()); 205 206 NoteView::new(self.note_context, &preview_note, *self.note_options) 207 .actionbar(false) 208 .options_button(false) 209 .show(ui); 210 } 211 }); 212 ui.separator(); 213 } 214 } 215 216 let current_zoom = ui.ctx().zoom_factor(); 217 218 ui.horizontal_wrapped(|ui| { 219 ui.label(richtext_small(tr!( 220 self.note_context.i18n, 221 "Zoom Level:", 222 "Label for zoom level, Appearance settings section", 223 ))); 224 225 let min_reached = current_zoom <= MIN_ZOOM; 226 let max_reached = current_zoom >= MAX_ZOOM; 227 228 if ui 229 .add_enabled( 230 !min_reached, 231 Button::new( 232 RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()), 233 ), 234 ) 235 .clicked() 236 { 237 let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM); 238 action = Some(SettingsAction::SetZoomFactor(new_zoom)); 239 }; 240 241 ui.label( 242 RichText::new(format!("{:.0}%", current_zoom * 100.0)) 243 .text_style(NotedeckTextStyle::Small.text_style()), 244 ); 245 246 if ui 247 .add_enabled( 248 !max_reached, 249 Button::new( 250 RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()), 251 ), 252 ) 253 .clicked() 254 { 255 let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM); 256 action = Some(SettingsAction::SetZoomFactor(new_zoom)); 257 }; 258 259 if ui 260 .button(richtext_small(tr!( 261 self.note_context.i18n, 262 "Reset", 263 "Label for reset zoom level, Appearance settings section", 264 ))) 265 .clicked() 266 { 267 action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM)); 268 } 269 }); 270 271 ui.horizontal_wrapped(|ui| { 272 ui.label(richtext_small(tr!( 273 self.note_context.i18n, 274 "Language:", 275 "Label for language, Appearance settings section", 276 ))); 277 278 // 279 ComboBox::from_label("") 280 .selected_text(self.get_selected_language_name()) 281 .show_ui(ui, |ui| { 282 for lang in self.note_context.i18n.get_available_locales() { 283 let name = self 284 .note_context 285 .i18n 286 .get_locale_native_name(lang) 287 .map(|s| s.to_owned()) 288 .unwrap_or_else(|| lang.to_string()); 289 if ui 290 .selectable_value(&mut self.settings.locale, lang.to_string(), name) 291 .clicked() 292 { 293 action = Some(SettingsAction::SetLocale(lang.to_owned())) 294 } 295 } 296 }); 297 }); 298 299 ui.horizontal_wrapped(|ui| { 300 ui.label(richtext_small(tr!( 301 self.note_context.i18n, 302 "Theme:", 303 "Label for theme, Appearance settings section", 304 ))); 305 306 if ui 307 .selectable_value( 308 &mut self.settings.theme, 309 ThemePreference::Light, 310 richtext_small(tr!( 311 self.note_context.i18n, 312 "Light", 313 "Label for Theme Light, Appearance settings section", 314 )), 315 ) 316 .clicked() 317 { 318 action = Some(SettingsAction::SetTheme(ThemePreference::Light)); 319 } 320 321 if ui 322 .selectable_value( 323 &mut self.settings.theme, 324 ThemePreference::Dark, 325 richtext_small(tr!( 326 self.note_context.i18n, 327 "Dark", 328 "Label for Theme Dark, Appearance settings section", 329 )), 330 ) 331 .clicked() 332 { 333 action = Some(SettingsAction::SetTheme(ThemePreference::Dark)); 334 } 335 }); 336 }); 337 338 action 339 } 340 341 pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 342 let id = ui.id(); 343 let mut action: Option<SettingsAction> = None; 344 let title = tr!( 345 self.note_context.i18n, 346 "Storage", 347 "Label for storage settings section" 348 ); 349 settings_group(ui, title, |ui| { 350 ui.horizontal_wrapped(|ui| { 351 let static_imgs_size = self 352 .note_context 353 .img_cache 354 .static_imgs 355 .cache_size 356 .lock() 357 .unwrap(); 358 359 let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap(); 360 361 ui.label( 362 RichText::new(format!( 363 "{} {}", 364 tr!( 365 self.note_context.i18n, 366 "Image cache size:", 367 "Label for Image cache size, Storage settings section" 368 ), 369 format_size( 370 [static_imgs_size, gifs_size] 371 .iter() 372 .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default()) 373 ) 374 )) 375 .text_style(NotedeckTextStyle::Small.text_style()), 376 ); 377 378 ui.end_row(); 379 380 if !notedeck::ui::is_compiled_as_mobile() 381 && ui 382 .button(richtext_small(tr!( 383 self.note_context.i18n, 384 "View folder", 385 "Label for view folder button, Storage settings section", 386 ))) 387 .clicked() 388 { 389 action = Some(SettingsAction::OpenCacheFolder); 390 } 391 392 let clearcache_resp = ui.button( 393 richtext_small(tr!( 394 self.note_context.i18n, 395 "Clear cache", 396 "Label for clear cache button, Storage settings section", 397 )) 398 .color(Color32::LIGHT_RED), 399 ); 400 401 let id_clearcache = id.with("clear_cache"); 402 if clearcache_resp.clicked() { 403 ui.data_mut(|d| d.insert_temp(id_clearcache, true)); 404 } 405 406 if ui.data_mut(|d| *d.get_temp_mut_or_default(id_clearcache)) { 407 let mut confirm_pressed = false; 408 clearcache_resp.show_tooltip_ui(|ui| { 409 let confirm_resp = ui.button(tr!( 410 self.note_context.i18n, 411 "Confirm", 412 "Label for confirm clear cache, Storage settings section" 413 )); 414 if confirm_resp.clicked() { 415 confirm_pressed = true; 416 } 417 418 if confirm_resp.clicked() 419 || ui 420 .button(tr!( 421 self.note_context.i18n, 422 "Cancel", 423 "Label for cancel clear cache, Storage settings section" 424 )) 425 .clicked() 426 { 427 ui.data_mut(|d| d.insert_temp(id_clearcache, false)); 428 } 429 }); 430 431 if confirm_pressed { 432 action = Some(SettingsAction::ClearCacheFolder); 433 } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() { 434 ui.data_mut(|d| d.insert_temp(id_clearcache, false)); 435 } 436 }; 437 }); 438 }); 439 440 action 441 } 442 443 fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 444 let mut action = None; 445 446 let title = tr!( 447 self.note_context.i18n, 448 "Others", 449 "Label for others settings section" 450 ); 451 settings_group(ui, title, |ui| { 452 ui.horizontal_wrapped(|ui| { 453 ui.label(richtext_small(tr!( 454 self.note_context.i18n, 455 "Sort replies newest first:", 456 "Label for Sort replies newest first, others settings section", 457 ))); 458 459 if ui 460 .toggle_value( 461 &mut self.settings.show_replies_newest_first, 462 RichText::new(tr!( 463 self.note_context.i18n, 464 "On", 465 "Setting to turn on sorting replies so that the newest are shown first" 466 )) 467 .text_style(NotedeckTextStyle::Small.text_style()), 468 ) 469 .changed() 470 { 471 action = Some(SettingsAction::SetRepliestNewestFirst( 472 self.settings.show_replies_newest_first, 473 )); 474 } 475 }); 476 477 ui.horizontal_wrapped(|ui| { 478 ui.label(richtext_small("Animate view transitions:")); 479 480 if ui 481 .toggle_value( 482 &mut self.settings.animate_nav_transitions, 483 RichText::new("On").text_style(NotedeckTextStyle::Small.text_style()), 484 ) 485 .changed() 486 { 487 action = Some(SettingsAction::SetAnimateNavTransitions( 488 self.settings.animate_nav_transitions, 489 )); 490 } 491 }); 492 493 ui.horizontal_wrapped(|ui| { 494 ui.label(richtext_small(tr!( 495 self.note_context.i18n, 496 "Max hashtags per note:", 497 "Label for max hashtags per note, others settings section", 498 ))); 499 500 if ui 501 .add( 502 egui::Slider::new(&mut self.settings.max_hashtags_per_note, 0..=20) 503 .text("") 504 .step_by(1.0), 505 ) 506 .changed() 507 { 508 action = Some(SettingsAction::SetMaxHashtagsPerNote( 509 self.settings.max_hashtags_per_note, 510 )); 511 }; 512 513 if ui 514 .button(richtext_small(tr!( 515 self.note_context.i18n, 516 "Reset", 517 "Label for reset max hashtags per note, others settings section", 518 ))) 519 .clicked() 520 { 521 action = Some(SettingsAction::SetMaxHashtagsPerNote( 522 DEFAULT_MAX_HASHTAGS_PER_NOTE, 523 )); 524 } 525 }); 526 527 ui.horizontal_wrapped(|ui| { 528 let text = if self.settings.max_hashtags_per_note == 0 { 529 tr!( 530 self.note_context.i18n, 531 "Hashtag filter disabled", 532 "Info text when hashtag filter is disabled (set to 0)" 533 ) 534 } else { 535 format!( 536 "Hide posts with more than {} hashtags", 537 self.settings.max_hashtags_per_note 538 ) 539 }; 540 ui.label( 541 richtext_small(&text).color(ui.visuals().gray_out(ui.visuals().text_color())), 542 ); 543 }); 544 }); 545 546 action 547 } 548 549 fn keys_section(&mut self, ui: &mut egui::Ui) { 550 let title = tr!( 551 self.note_context.i18n, 552 "Keys", 553 "label for keys setting section" 554 ); 555 556 settings_group(ui, title, |ui| { 557 ui.horizontal_wrapped(|ui| { 558 ui.label( 559 richtext_small(tr!( 560 self.note_context.i18n, 561 "PUBLIC ACCOUNT ID", 562 "label describing public key" 563 )) 564 .color(ui.visuals().gray_out(ui.visuals().text_color())), 565 ); 566 }); 567 568 let copy_img = if ui.visuals().dark_mode { 569 copy_to_clipboard_image() 570 } else { 571 copy_to_clipboard_dark_image() 572 }; 573 let copy_max_size = vec2(16.0, 16.0); 574 575 if let Some(npub) = self.note_context.accounts.selected_account_pubkey().npub() { 576 item_frame(ui).show(ui, |ui| { 577 StripBuilder::new(ui) 578 .size(Size::exact(24.0)) 579 .cell_layout(Layout::left_to_right(egui::Align::Center)) 580 .vertical(|mut strip| { 581 strip.strip(|builder| { 582 builder 583 .size(Size::remainder()) 584 .size(Size::exact(16.0)) 585 .cell_layout(Layout::left_to_right(egui::Align::Center)) 586 .horizontal(|mut strip| { 587 strip.cell(|ui| { 588 ui.horizontal_wrapped(|ui| { 589 ui.label(richtext_small(&npub)); 590 }); 591 }); 592 593 strip.cell(|ui| { 594 let helper = AnimationHelper::new( 595 ui, 596 "copy-to-clipboard-npub", 597 copy_max_size, 598 ); 599 600 copy_img.paint_at(ui, helper.scaled_rect()); 601 602 if helper.take_animation_response().clicked() { 603 ui.ctx().copy_text(npub); 604 } 605 }); 606 }); 607 }); 608 }); 609 }); 610 } 611 612 let Some(filled) = self.note_context.accounts.selected_filled() else { 613 return; 614 }; 615 let Some(mut nsec) = bech32::encode::<bech32::Bech32>( 616 bech32::Hrp::parse_unchecked("nsec"), 617 &filled.secret_key.secret_bytes(), 618 ) 619 .ok() else { 620 return; 621 }; 622 623 ui.horizontal_wrapped(|ui| { 624 ui.label( 625 richtext_small(tr!( 626 self.note_context.i18n, 627 "SECRET ACCOUNT LOGIN KEY", 628 "label describing secret key" 629 )) 630 .color(ui.visuals().gray_out(ui.visuals().text_color())), 631 ); 632 }); 633 634 let is_password_id = ui.id().with("is-password"); 635 let is_password = ui 636 .ctx() 637 .data_mut(|d| d.get_temp(is_password_id)) 638 .unwrap_or(true); 639 640 item_frame(ui).show(ui, |ui| { 641 StripBuilder::new(ui) 642 .size(Size::exact(24.0)) 643 .cell_layout(Layout::left_to_right(egui::Align::Center)) 644 .vertical(|mut strip| { 645 strip.strip(|builder| { 646 builder 647 .size(Size::remainder()) 648 .size(Size::exact(48.0)) 649 .cell_layout(Layout::left_to_right(egui::Align::Center)) 650 .horizontal(|mut strip| { 651 strip.cell(|ui| { 652 if is_password { 653 ui.add( 654 TextEdit::singleline(&mut nsec) 655 .password(is_password) 656 .interactive(false) 657 .frame(false), 658 ); 659 } else { 660 ui.horizontal_wrapped(|ui| { 661 ui.label(richtext_small(&nsec)); 662 }); 663 } 664 }); 665 666 strip.cell(|ui| { 667 let helper = AnimationHelper::new( 668 ui, 669 "copy-to-clipboard-nsec", 670 copy_max_size, 671 ); 672 673 copy_img.paint_at(ui, helper.scaled_rect()); 674 675 if helper.take_animation_response().clicked() { 676 ui.ctx().copy_text(nsec); 677 } 678 679 if eye_button(ui, is_password).clicked() { 680 ui.ctx().data_mut(|d| { 681 d.insert_temp(is_password_id, !is_password) 682 }); 683 } 684 }); 685 }); 686 }); 687 }); 688 }); 689 }); 690 } 691 692 fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { 693 let mut action = None; 694 695 if ui 696 .add_sized( 697 [ui.available_width(), 30.0], 698 Button::new(richtext_small(tr!( 699 self.note_context.i18n, 700 "Configure relays", 701 "Label for configure relays, settings section", 702 ))), 703 ) 704 .clicked() 705 { 706 action = Some(SettingsAction::OpenRelays); 707 } 708 709 action 710 } 711 712 pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<SettingsAction> { 713 let scroll_out = Frame::default() 714 .inner_margin(Margin::symmetric(10, 10)) 715 .show(ui, |ui| { 716 ScrollArea::vertical().show(ui, |ui| { 717 let mut action = None; 718 if let Some(new_action) = self.appearance_section(ui) { 719 action = Some(new_action); 720 } 721 722 ui.add_space(5.0); 723 724 if let Some(new_action) = self.storage_section(ui) { 725 action = Some(new_action); 726 } 727 728 ui.add_space(5.0); 729 730 self.keys_section(ui); 731 732 ui.add_space(5.0); 733 734 if let Some(new_action) = self.other_options_section(ui) { 735 action = Some(new_action); 736 } 737 738 ui.add_space(10.0); 739 740 if let Some(new_action) = self.manage_relays_section(ui) { 741 action = Some(new_action); 742 } 743 action 744 }) 745 }) 746 .inner; 747 748 DragResponse::scroll(scroll_out) 749 } 750 } 751 752 pub fn format_size(size_bytes: u64) -> String { 753 const KB: f64 = 1024.0; 754 const MB: f64 = KB * 1024.0; 755 const GB: f64 = MB * 1024.0; 756 757 let size = size_bytes as f64; 758 759 if size < KB { 760 format!("{size:.0} Bytes") 761 } else if size < MB { 762 format!("{:.1} KB", size / KB) 763 } else if size < GB { 764 format!("{:.1} MB", size / MB) 765 } else { 766 format!("{:.2} GB", size / GB) 767 } 768 } 769 770 fn item_frame(ui: &egui::Ui) -> egui::Frame { 771 Frame::new() 772 .inner_margin(Margin::same(8)) 773 .corner_radius(CornerRadius::same(8)) 774 .fill(ui.visuals().panel_fill) 775 }