notedeck

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

commit fe30704496f285d1578cef290417b2d4e277a4e3
parent 56cbf68ea510e325831471bc340e9eb4deb26b41
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 23 Jul 2025 12:00:29 -0700

Merge remote-tracking branch 'fernando/feat/settings-view'

Diffstat:
M.gitignore | 3+++
MCargo.lock | 32++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Aandroid | 12++++++++++++
Mcrates/notedeck/src/account/relay.rs | 3+--
Mcrates/notedeck/src/imgcache.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck/src/urls.rs | 11+++++++++++
Mcrates/notedeck_chrome/src/chrome.rs | 34+++++++++++++++++++++++++++++++++-
Mcrates/notedeck_chrome/src/setup.rs | 2++
Mcrates/notedeck_columns/Cargo.toml | 1+
Mcrates/notedeck_columns/src/app.rs | 49+++++++++++++++++++++++++++++--------------------
Mcrates/notedeck_columns/src/args.rs | 9++++++---
Mcrates/notedeck_columns/src/nav.rs | 41+++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_columns/src/route.rs | 18++++++++++++++++++
Mcrates/notedeck_columns/src/ui/column/header.rs | 1+
Mcrates/notedeck_columns/src/ui/mod.rs | 2++
Acrates/notedeck_columns/src/ui/settings.rs | 448+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_ui/src/note/contents.rs | 5++++-
Mcrates/notedeck_ui/src/note/media.rs | 14+++++++-------
Mcrates/notedeck_ui/src/note/options.rs | 3++-
Mcrates/notedeck_ui/src/profile/mod.rs | 2++
21 files changed, 725 insertions(+), 38 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -19,3 +19,5 @@ queries/damus-notifs.json .direnv/ scripts/macos_build_secrets.sh /tags +.zed +.lsp +\ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock @@ -803,6 +803,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" [[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "regex-automata 0.4.9", + "serde", +] + +[[package]] name = "built" version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3305,6 +3316,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] +name = "normpath" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] name = "nostr" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3500,6 +3520,7 @@ dependencies = [ "notedeck_ui", "oot_bitset", "open", + "opener", "poll-promise", "pretty_assertions", "profiling", @@ -4003,6 +4024,17 @@ dependencies = [ ] [[package]] +name = "opener" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771b9704f8cd8b424ec747a320b30b47517a6966ba2c7da90047c16f4a962223" +dependencies = [ + "bstr", + "normpath", + "windows-sys 0.59.0", +] + +[[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -12,6 +12,7 @@ members = [ ] [workspace.dependencies] +opener = "0.8.2" base32 = "0.4.0" base64 = "0.22.1" rmpv = "1.3.0" diff --git a/android b/android @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +root_dir=$PWD + +cargo ndk --target arm64-v8a -o ./crates/notedeck_chrome/android/app/src/main/jniLibs/ build --profile release + +cd ./crates/notedeck_chrome/android + +./gradlew build && ./gradlew installDebug + +cd $root_dir +\ No newline at end of file diff --git a/crates/notedeck/src/account/relay.rs b/crates/notedeck/src/account/relay.rs @@ -1,12 +1,11 @@ use std::collections::BTreeSet; +use crate::{AccountData, RelaySpec}; use enostr::{Keypair, Pubkey, RelayPool}; use nostrdb::{Filter, Ndb, NoteBuilder, NoteKey, Subscription, Transaction}; use tracing::{debug, error, info}; use url::Url; -use crate::{AccountData, RelaySpec}; - #[derive(Clone)] pub(crate) struct AccountRelayData { pub filter: Filter, diff --git a/crates/notedeck/src/imgcache.rs b/crates/notedeck/src/imgcache.rs @@ -7,9 +7,11 @@ use poll_promise::Promise; use egui::ColorImage; use std::collections::HashMap; -use std::fs::{create_dir_all, File}; +use std::fs::{self, create_dir_all, File}; use std::sync::mpsc::Receiver; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; +use std::{io, thread}; use hex::ToHex; use sha2::Digest; @@ -220,6 +222,7 @@ pub struct MediaCache { pub cache_dir: path::PathBuf, pub textures_cache: TexturesCache, pub cache_type: MediaCacheType, + pub cache_size: Arc<Mutex<Option<u64>>>, } #[derive(Debug, Eq, PartialEq, Clone, Copy)] @@ -231,10 +234,29 @@ pub enum MediaCacheType { impl MediaCache { pub fn new(parent_dir: &Path, cache_type: MediaCacheType) -> Self { let cache_dir = parent_dir.join(Self::rel_dir(cache_type)); + + let cache_dir_clone = cache_dir.clone(); + let cache_size = Arc::new(Mutex::new(None)); + let cache_size_clone = Arc::clone(&cache_size); + + thread::spawn(move || { + let mut last_checked = Instant::now() - Duration::from_secs(999); + loop { + // check cache folder size every 60 s + if last_checked.elapsed() >= Duration::from_secs(60) { + let size = compute_folder_size(&cache_dir_clone); + *cache_size_clone.lock().unwrap() = Some(size); + last_checked = Instant::now(); + } + thread::sleep(Duration::from_secs(5)); + } + }); + Self { cache_dir, textures_cache: TexturesCache::default(), cache_type, + cache_size, } } @@ -331,8 +353,14 @@ impl MediaCache { ); } } + Ok(()) } + + fn clear(&mut self) { + self.textures_cache.cache.clear(); + *self.cache_size.try_lock().unwrap() = Some(0); + } } fn color_image_to_rgba(color_image: ColorImage) -> image::RgbaImage { @@ -349,7 +377,28 @@ fn color_image_to_rgba(color_image: ColorImage) -> image::RgbaImage { .expect("Failed to create RgbaImage from ColorImage") } +fn compute_folder_size<P: AsRef<Path>>(path: P) -> u64 { + fn walk(path: &Path) -> u64 { + let mut size = 0; + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let path = entry.path(); + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + size += metadata.len(); + } else if metadata.is_dir() { + size += walk(&path); + } + } + } + } + size + } + walk(path.as_ref()) +} + pub struct Images { + pub base_path: path::PathBuf, pub static_imgs: MediaCache, pub gifs: MediaCache, pub urls: UrlMimes, @@ -360,6 +409,7 @@ impl Images { /// path to directory to place [`MediaCache`]s pub fn new(path: path::PathBuf) -> Self { Self { + base_path: path.clone(), static_imgs: MediaCache::new(&path, MediaCacheType::Image), gifs: MediaCache::new(&path, MediaCacheType::Gif), urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))), @@ -385,6 +435,26 @@ impl Images { MediaCacheType::Gif => &mut self.gifs, } } + + pub fn clear_folder_contents(&mut self) -> io::Result<()> { + for entry in fs::read_dir(self.base_path.clone())? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + + self.urls.cache.clear(); + self.static_imgs.clear(); + self.gifs.clear(); + self.gif_states.clear(); + + Ok(()) + } } pub type GifStateMap = HashMap<String, GifState>; diff --git a/crates/notedeck/src/urls.rs b/crates/notedeck/src/urls.rs @@ -68,6 +68,17 @@ impl UrlCache { } } } + + pub fn clear(&mut self) { + if self.from_disk_promise.is_none() { + let cache = self.cache.clone(); + std::thread::spawn(move || { + if let Ok(mut locked_cache) = cache.write() { + locked_cache.clear(); + } + }); + } + } } fn merge_cache(cur_cache: Arc<RwLock<UrlsToMime>>, from_disk: UrlsToMime) { diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -151,7 +151,7 @@ impl ChromePanelAction { } Self::Settings => { - Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Relays); + Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings); } Self::Wallet => { @@ -775,6 +775,38 @@ fn pfp_button(ctx: &mut AppContext, ui: &mut egui::Ui) -> egui::Response { ui.put(helper.get_animation_rect(), &mut widget); helper.take_animation_response() + + // let selected = ctx.accounts.cache.selected(); + + // pfp_resp.context_menu(|ui| { + // for (pk, account) in &ctx.accounts.cache { + // let profile = ctx.ndb.get_profile_by_pubkey(&txn, pk).ok(); + // let is_selected = *pk == selected.key.pubkey; + // let has_nsec = account.key.secret_key.is_some(); + + // let profile_peview_view = { + // let max_size = egui::vec2(ui.available_width(), 77.0); + // let resp = ui.allocate_response(max_size, egui::Sense::click()); + // ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| { + // ui.add( + // &mut ProfilePic::new(ctx.img_cache, get_profile_url(profile.as_ref())) + // .size(24.0), + // ) + // }) + // }; + + // // if let Some(op) = profile_peview_view { + // // return_op = Some(match op { + // // ProfilePreviewAction::SwitchTo => AccountsViewResponse::SelectAccount(*pk), + // // ProfilePreviewAction::RemoveAccount => AccountsViewResponse::RemoveAccount(*pk), + // // }); + // // } + // } + // // if ui.menu_image_button(image, add_contents).clicked() { + // // // ui.ctx().copy_text(url.to_owned()); + // // ui.close_menu(); + // // } + // }); } /// The section of the chrome sidebar that starts at the diff --git a/crates/notedeck_chrome/src/setup.rs b/crates/notedeck_chrome/src/setup.rs @@ -29,6 +29,7 @@ pub fn setup_chrome(ctx: &egui::Context, args: &notedeck::Args, theme: ThemePref }); ctx.set_visuals_of(egui::Theme::Dark, theme::dark_mode(is_oled)); ctx.set_visuals_of(egui::Theme::Light, theme::light_mode()); + setup_cc(ctx, is_mobile); } @@ -38,6 +39,7 @@ 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/Cargo.toml b/crates/notedeck_columns/Cargo.toml @@ -11,6 +11,7 @@ description = "A tweetdeck-style notedeck app" crate-type = ["lib", "cdylib"] [dependencies] +opener = { workspace = true } rmpv = { workspace = true } bech32 = { workspace = true } notedeck = { workspace = true } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -38,6 +38,7 @@ pub enum DamusState { /// We derive Deserialize/Serialize so we can persist app state on shutdown. pub struct Damus { state: DamusState, + pub decks_cache: DecksCache, pub view_state: ViewState, pub drafts: Drafts, @@ -393,13 +394,13 @@ fn determine_key_storage_type() -> KeyStorageType { impl Damus { /// Called once before the first frame. - pub fn new(ctx: &mut AppContext<'_>, args: &[String]) -> Self { + pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self { // arg parsing let (parsed_args, unrecognized_args) = - ColumnsArgs::parse(args, Some(ctx.accounts.selected_account_pubkey())); + ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey())); - let account = ctx.accounts.selected_account_pubkey_bytes(); + let account = app_context.accounts.selected_account_pubkey_bytes(); let mut timeline_cache = TimelineCache::default(); let mut options = AppOptions::default(); @@ -409,31 +410,34 @@ impl Damus { let decks_cache = if tmp_columns { info!("DecksCache: loading from command line arguments"); let mut columns: Columns = Columns::new(); - let txn = Transaction::new(ctx.ndb).unwrap(); + let txn = Transaction::new(app_context.ndb).unwrap(); for col in &parsed_args.columns { let timeline_kind = col.clone().into_timeline_kind(); if let Some(add_result) = columns.add_new_timeline_column( &mut timeline_cache, &txn, - ctx.ndb, - ctx.note_cache, - ctx.pool, + app_context.ndb, + app_context.note_cache, + app_context.pool, &timeline_kind, ) { add_result.process( - ctx.ndb, - ctx.note_cache, + app_context.ndb, + app_context.note_cache, &txn, &mut timeline_cache, - ctx.unknown_ids, + app_context.unknown_ids, ); } } - columns_to_decks_cache(ctx.i18n, columns, account) - } else if let Some(decks_cache) = - crate::storage::load_decks_cache(ctx.path, ctx.ndb, &mut timeline_cache, ctx.i18n) - { + columns_to_decks_cache(app_context.i18n, columns, account) + } else if let Some(decks_cache) = crate::storage::load_decks_cache( + app_context.path, + app_context.ndb, + &mut timeline_cache, + app_context.i18n, + ) { info!( "DecksCache: loading from disk {}", crate::storage::DECKS_CACHE_FILE @@ -441,13 +445,13 @@ impl Damus { decks_cache } else { info!("DecksCache: creating new with demo configuration"); - DecksCache::new_with_demo_config(&mut timeline_cache, ctx) - //for (pk, _) in &ctx.accounts.cache { + DecksCache::new_with_demo_config(&mut timeline_cache, app_context) + //for (pk, _) in &app_context.accounts.cache { // cache.add_deck_default(*pk); //} }; - let support = Support::new(ctx.path); + let support = Support::new(app_context.path); let mut note_options = NoteOptions::default(); note_options.set( NoteOptions::Textmode, @@ -462,10 +466,14 @@ impl Damus { parsed_args.is_flag_set(ColumnsFlag::NoMedia), ); note_options.set( - NoteOptions::ShowNoteClient, - parsed_args.is_flag_set(ColumnsFlag::ShowNoteClient), + NoteOptions::ShowNoteClientTop, + parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientTop), + ); + note_options.set( + NoteOptions::ShowNoteClientBottom, + parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientBottom), ); - options.set(AppOptions::Debug, ctx.args.debug); + options.set(AppOptions::Debug, app_context.args.debug); options.set( AppOptions::SinceOptimize, parsed_args.is_flag_set(ColumnsFlag::SinceOptimize), @@ -662,6 +670,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { Route::Reply(_) => false, Route::Quote(_) => false, Route::Relays => false, + Route::Settings => false, Route::ComposeNote => false, Route::AddColumn(_) => false, Route::EditProfile(_) => false, diff --git a/crates/notedeck_columns/src/args.rs b/crates/notedeck_columns/src/args.rs @@ -11,7 +11,8 @@ pub enum ColumnsFlag { Textmode, Scramble, NoMedia, - ShowNoteClient, + ShowNoteClientTop, + ShowNoteClientBottom, } pub struct ColumnsArgs { @@ -53,8 +54,10 @@ impl ColumnsArgs { res.clear_flag(ColumnsFlag::SinceOptimize); } else if arg == "--scramble" { res.set_flag(ColumnsFlag::Scramble); - } else if arg == "--show-note-client" { - res.set_flag(ColumnsFlag::ShowNoteClient); + } else if arg == "--show-note-client=top" { + res.set_flag(ColumnsFlag::ShowNoteClientTop); + } else if arg == "--show-note-client=bottom" { + res.set_flag(ColumnsFlag::ShowNoteClientBottom); } else if arg == "--no-media" { res.set_flag(ColumnsFlag::NoMedia); } else if arg == "--filter" { diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -20,9 +20,10 @@ use crate::{ note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType}, profile::EditProfileView, search::{FocusState, SearchView}, + settings::{SettingsAction, ShowNoteClientOptions}, support::SupportView, wallet::{get_default_zap_state, WalletAction, WalletState, WalletView}, - RelayView, + RelayView, SettingsView, }, Damus, }; @@ -34,6 +35,7 @@ use notedeck::{ get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteContext, RelayAction, }; +use notedeck_ui::NoteOptions; use tracing::error; /// The result of processing a nav response @@ -60,6 +62,7 @@ pub enum RenderNavAction { SwitchingAction(SwitchingAction), WalletAction(WalletAction), RelayAction(RelayAction), + SettingsAction(SettingsAction), } pub enum SwitchingAction { @@ -480,6 +483,10 @@ fn process_render_nav_action( .process_relay_action(ui.ctx(), ctx.pool, action); None } + RenderNavAction::SettingsAction(action) => { + action.process(app, ctx.theme, ctx.i18n, ctx.img_cache, ui.ctx()); + None + } }; if let Some(action) = router_action { @@ -497,7 +504,7 @@ fn process_render_nav_action( fn render_nav_body( ui: &mut egui::Ui, app: &mut Damus, - ctx: &mut AppContext<'_>, + ctx: &mut AppContext, top: &Route, depth: usize, col: usize, @@ -571,6 +578,36 @@ fn render_nav_body( Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map, ctx.i18n) .ui(ui) .map(RenderNavAction::RelayAction), + + Route::Settings => { + let mut show_note_client = if app.note_options.contains(NoteOptions::ShowNoteClientTop) + { + ShowNoteClientOptions::Top + } else if app.note_options.contains(NoteOptions::ShowNoteClientBottom) { + ShowNoteClientOptions::Bottom + } else { + ShowNoteClientOptions::Hide + }; + + let mut 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::Reply(id) => { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { txn diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -19,6 +19,7 @@ pub enum Route { Reply(NoteId), Quote(NoteId), Relays, + Settings, ComposeNote, AddColumn(AddColumnRoute), EditProfile(Pubkey), @@ -47,6 +48,10 @@ impl Route { Route::Relays } + pub fn settings() -> Self { + Route::Settings + } + pub fn thread(thread_selection: ThreadSelection) -> Self { Route::Thread(thread_selection) } @@ -110,6 +115,9 @@ impl Route { Route::Relays => { writer.write_token("relay"); } + Route::Settings => { + writer.write_token("settings"); + } Route::ComposeNote => { writer.write_token("compose"); } @@ -171,6 +179,12 @@ impl Route { }, |p| { p.parse_all(|p| { + p.parse_token("settings")?; + Ok(Route::Settings) + }) + }, + |p| { + p.parse_all(|p| { p.parse_token("quote")?; Ok(Route::Quote(NoteId::new(tokenator::parse_hex_id(p)?))) }) @@ -250,6 +264,9 @@ impl Route { Route::Relays => { ColumnTitle::formatted(tr!(i18n, "Relays", "Column title for relay management")) } + Route::Settings => { + ColumnTitle::formatted(tr!(i18n, "Settings", "Column title for app settings")) + } Route::Accounts(amr) => match amr { AccountsRoute::Accounts => ColumnTitle::formatted(tr!( i18n, @@ -555,6 +572,7 @@ impl fmt::Display for Route { write!(f, "{}", tr!("Quote", "Display name for quote composition")) } Route::Relays => write!(f, "{}", tr!("Relays", "Display name for relay management")), + Route::Settings => write!(f, "{}", tr!("Settings", "Display name for settings management")), Route::Accounts(amr) => match amr { AccountsRoute::Accounts => write!( f, diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -481,6 +481,7 @@ impl<'a> NavTitle<'a> { Route::AddColumn(_add_col_route) => None, Route::Support => None, Route::Relays => None, + Route::Settings => None, Route::NewDeck => None, Route::EditDeck(_) => None, Route::EditProfile(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)), diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs @@ -12,6 +12,7 @@ pub mod profile; pub mod relay; pub mod search; pub mod search_results; +pub mod settings; pub mod side_panel; pub mod support; pub mod thread; @@ -24,6 +25,7 @@ pub use note::{PostReplyView, PostView}; pub use preview::{Preview, PreviewApp, PreviewConfig}; pub use profile::ProfileView; pub use relay::RelayView; +pub use settings::SettingsView; 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 @@ -0,0 +1,448 @@ +use egui::{vec2, Button, Color32, ComboBox, Frame, Margin, RichText, ThemePreference}; +use notedeck::{tr, Images, LanguageIdentifier, Localization, NotedeckTextStyle, ThemeHandler}; +use notedeck_ui::NoteOptions; +use strum::Display; + +use crate::{nav::RouterAction, Damus, Route}; + +#[derive(Clone, Copy, PartialEq, Eq, Display)] +pub enum ShowNoteClientOptions { + Hide, + Top, + Bottom, +} + +pub enum SettingsAction { + SetZoom(f32), + SetTheme(ThemePreference), + SetShowNoteClient(ShowNoteClientOptions), + SetLocale(LanguageIdentifier), + OpenRelays, + OpenCacheFolder, + ClearCacheFolder, +} + +impl SettingsAction { + pub fn process<'a>( + self, + app: &mut Damus, + theme_handler: &'a mut ThemeHandler, + i18n: &'a mut Localization, + img_cache: &mut Images, + ctx: &egui::Context, + ) -> Option<RouterAction> { + let mut route_action: Option<RouterAction> = None; + + match self { + SettingsAction::OpenRelays => { + route_action = Some(RouterAction::route_to(Route::Relays)) + } + SettingsAction::SetZoom(zoom_level) => { + ctx.set_zoom_factor(zoom_level); + } + SettingsAction::SetShowNoteClient(newvalue) => match newvalue { + ShowNoteClientOptions::Hide => { + app.note_options.set(NoteOptions::ShowNoteClientTop, false); + app.note_options + .set(NoteOptions::ShowNoteClientBottom, false); + } + ShowNoteClientOptions::Bottom => { + app.note_options.set(NoteOptions::ShowNoteClientTop, false); + app.note_options + .set(NoteOptions::ShowNoteClientBottom, true); + } + ShowNoteClientOptions::Top => { + app.note_options.set(NoteOptions::ShowNoteClientTop, true); + app.note_options + .set(NoteOptions::ShowNoteClientBottom, false); + } + }, + SettingsAction::SetTheme(theme) => { + ctx.options_mut(|o| { + o.theme_preference = theme; + }); + theme_handler.save(theme); + } + SettingsAction::SetLocale(language) => { + _ = i18n.set_locale(language); + } + SettingsAction::OpenCacheFolder => { + use opener; + let _ = opener::open(img_cache.base_path.clone()); + } + SettingsAction::ClearCacheFolder => { + let _ = img_cache.clear_folder_contents(); + } + } + route_action + } +} + +pub struct SettingsView<'a> { + theme: &'a mut String, + selected_language: &'a mut String, + show_note_client: &'a mut ShowNoteClientOptions, + i18n: &'a mut Localization, + img_cache: &'a mut Images, +} + +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 ShowNoteClientOptions, + i18n: &'a mut Localization, + ) -> Self { + Self { + show_note_client, + theme, + img_cache, + selected_language, + i18n, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> { + let id = ui.id(); + let mut action = None; + + 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::SetZoom(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::SetZoom(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::SetZoom(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.selected_language.to_owned()) + .show_ui(ui, |ui| { + for lang in self.i18n.get_available_locales() { + if ui + .selectable_value( + self.selected_language, + lang.to_string(), + lang.to_string(), + ) + .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)); + } + }); + }); + }); + + 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)); + } + }; + }); + }); + }); + + 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 [ + ShowNoteClientOptions::Hide, + ShowNoteClientOptions::Top, + ShowNoteClientOptions::Bottom, + ] { + let label = option.clone().to_string(); + + if ui + .selectable_value( + self.show_note_client, + option, + RichText::new(label) + .text_style(NotedeckTextStyle::Small.text_style()), + ) + .changed() + { + action = Some(SettingsAction::SetShowNoteClient(option)); + } + } + }); + }); + }); + + ui.add_space(10.0); + + if ui + .add_sized( + [ui.available_width(), 30.0], + Button::new( + RichText::new(tr!( + self.i18n, + "Configure relays", + "Label for configure relays, settings section" + )) + .text_style(NotedeckTextStyle::Small.text_style()), + ), + ) + .clicked() + { + action = Some(SettingsAction::OpenRelays); + } + }); + + action + } +} + +pub fn format_size(size_bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + + let size = size_bytes as f64; + + if size < KB { + format!("{size:.0} Bytes") + } else if size < MB { + format!("{:.1} KB", size / KB) + } else if size < GB { + format!("{:.1} MB", size / MB) + } else { + format!("{:.2} GB", size / GB) + } +} diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -46,6 +46,9 @@ impl<'a, 'd> NoteContents<'a, 'd> { impl egui::Widget for &mut NoteContents<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { + if self.options.contains(NoteOptions::ShowNoteClientTop) { + render_client(ui, self.note_context.note_cache, self.note); + } let result = render_note_contents( ui, self.note_context, @@ -54,7 +57,7 @@ impl egui::Widget for &mut NoteContents<'_, '_> { self.options, self.jobs, ); - if self.options.contains(NoteOptions::ShowNoteClient) { + if self.options.contains(NoteOptions::ShowNoteClientBottom) { render_client(ui, self.note_context.note_cache, self.note); } self.action = result.action; diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -235,13 +235,13 @@ fn get_selected_index(ui: &egui::Ui, selection_id: egui::Id) -> usize { /// Checks to see if we have any left/right key presses and updates the carousel index fn update_selected_image_index(ui: &mut egui::Ui, carousel_id: egui::Id, num_urls: i32) -> usize { if num_urls > 1 { - let next_image = ui.data(|data| { - data.get_temp(carousel_id.with("next_image")) - .unwrap_or(false) - }); - let prev_image = ui.data(|data| { - data.get_temp(carousel_id.with("prev_image")) - .unwrap_or(false) + let (next_image, prev_image) = ui.data(|data| { + ( + data.get_temp(carousel_id.with("next_image")) + .unwrap_or_default(), + data.get_temp(carousel_id.with("prev_image")) + .unwrap_or_default(), + ) }); if next_image diff --git a/crates/notedeck_ui/src/note/options.rs b/crates/notedeck_ui/src/note/options.rs @@ -23,7 +23,8 @@ bitflags! { /// will end with a ... and a "Show more" button. const Truncate = 1 << 11; /// Show note's client in the note header - const ShowNoteClient = 1 << 12; + const ShowNoteClientTop = 1 << 12; + const ShowNoteClientBottom = 1 << 13; } } diff --git a/crates/notedeck_ui/src/profile/mod.rs b/crates/notedeck_ui/src/profile/mod.rs @@ -45,6 +45,8 @@ pub fn display_name_widget<'a>( let nip05_resp = name.nip05.map(|nip05| { ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + ui.add(app_images::verified_image()); ui.label(RichText::new(nip05).size(16.0).color(crate::colors::TEAL))