notedeck

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

commit 3d4db820b49f30c292790b3e7668b8c239f0cd37
parent d1e222f732e7004bf1a92b8efc85e32b26ba6a2f
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 29 Jun 2025 11:05:31 -0700

i18n: make localization context non-global

- Simplify Localization{Context,Manager} to just Localization
- Fixed a bunch of lifetime issueo
- Removed all Arcs and Locks
- Removed globals
  * widgets now need access to &mut Localization for i18n

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 31+++++++++++++++++++++++++++++++
MCargo.toml | 2+-
Mcrates/notedeck/src/app.rs | 20+++++---------------
Mcrates/notedeck/src/context.rs | 4++--
Acrates/notedeck/src/i18n/error.rs | 24++++++++++++++++++++++++
Acrates/notedeck/src/i18n/key.rs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck/src/i18n/manager.rs | 763++++++++++++++++++++++++++++++++-----------------------------------------------
Mcrates/notedeck/src/i18n/mod.rs | 208++++++++++++++++++-------------------------------------------------------------
Mcrates/notedeck/src/lib.rs | 8+-------
Mcrates/notedeck/src/note/mod.rs | 2++
Mcrates/notedeck/src/notecache.rs | 12+++++++-----
Mcrates/notedeck/src/time.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/notedeck_chrome/src/chrome.rs | 39++++++++++++++++++++++++---------------
Mcrates/notedeck_columns/src/accounts/mod.rs | 36++++++++++++++++++++++++------------
Mcrates/notedeck_columns/src/actionbar.rs | 2+-
Mcrates/notedeck_columns/src/app.rs | 54++++++++++++++++++++++++++++++++++--------------------
Mcrates/notedeck_columns/src/decks.rs | 77++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/notedeck_columns/src/login_manager.rs | 18+++++++++++-------
Mcrates/notedeck_columns/src/nav.rs | 78+++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcrates/notedeck_columns/src/route.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/notedeck_columns/src/storage/decks.rs | 8+++++---
Mcrates/notedeck_columns/src/timeline/kind.rs | 17++++++++++-------
Mcrates/notedeck_columns/src/timeline/mod.rs | 14+++++++++-----
Mcrates/notedeck_columns/src/ui/account_login_view.rs | 56++++++++++++++++++++++++++++++++++----------------------
Mcrates/notedeck_columns/src/ui/accounts.rs | 52++++++++++++++++++++++++++++++++++------------------
Mcrates/notedeck_columns/src/ui/add_column.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/notedeck_columns/src/ui/column/header.rs | 29++++++++++++++++++++---------
Mcrates/notedeck_columns/src/ui/configure_deck.rs | 40++++++++++++++++++++++++++--------------
Mcrates/notedeck_columns/src/ui/edit_deck.rs | 22+++++++++-------------
Mcrates/notedeck_columns/src/ui/note/custom_zap.rs | 67++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/notedeck_columns/src/ui/note/post.rs | 15+++++++++++----
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 49++++++++++++++++++++++++++++++++++++++-----------
Mcrates/notedeck_columns/src/ui/profile/mod.rs | 19+++++++++++++------
Mcrates/notedeck_columns/src/ui/relay.rs | 44++++++++++++++++++++++++++++----------------
Mcrates/notedeck_columns/src/ui/search/mod.rs | 7++++++-
Mcrates/notedeck_columns/src/ui/side_panel.rs | 27+++++++++++++++++++--------
Mcrates/notedeck_columns/src/ui/support.rs | 45++++++++++++++++++++++++++++++++-------------
Mcrates/notedeck_columns/src/ui/timeline.rs | 26+++++++++++++++++++++-----
Mcrates/notedeck_columns/src/ui/wallet.rs | 62++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/notedeck_dave/src/ui/dave.rs | 15+++++++++------
Mcrates/notedeck_ui/src/note/contents.rs | 1+
Mcrates/notedeck_ui/src/note/context.rs | 18++++++++----------
Mcrates/notedeck_ui/src/note/media.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/notedeck_ui/src/note/mod.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mcrates/notedeck_ui/src/note/reply_description.rs | 26++++++++++++++++++++------
Mcrates/notedeck_ui/src/profile/preview.rs | 23++++++++++++++++-------
Mcrates/notedeck_ui/src/username.rs | 11+++++++++--
47 files changed, 1405 insertions(+), 1157 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4340,6 +4340,12 @@ dependencies = [ ] [[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] name = "proc-macro2" version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6064,6 +6070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" dependencies = [ "unic-langid-impl", + "unic-langid-macros", ] [[package]] @@ -6076,6 +6083,30 @@ dependencies = [ ] [[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn 2.0.104", + "unic-langid-impl", +] + +[[package]] name = "unicase" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -65,7 +65,7 @@ tracing = { version = "0.1.40", features = ["log"] } tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tempfile = "3.13.0" -unic-langid = "0.9.6" +unic-langid = { version = "0.9.6", features = ["macros"] } url = "2.5.2" urlencoding = "2.1.3" uuid = { version = "1.10.0", features = ["v4"] } diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs @@ -1,5 +1,5 @@ use crate::account::FALLBACK_PUBKEY; -use crate::i18n::{LocalizationContext, LocalizationManager}; +use crate::i18n::Localization; use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::wallet::GlobalWallet; use crate::zaps::Zaps; @@ -18,7 +18,6 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::path::Path; use std::rc::Rc; -use std::sync::Arc; use tracing::{error, info}; pub enum AppAction { @@ -50,7 +49,7 @@ pub struct Notedeck { zaps: Zaps, frame_history: FrameHistory, job_pool: JobPool, - i18n: LocalizationContext, + i18n: Localization, } /// Our chrome, which is basically nothing @@ -231,19 +230,10 @@ impl Notedeck { let job_pool = JobPool::default(); // Initialize localization - let i18n_resource_dir = Path::new("assets/translations"); - let localization_manager = Arc::new( - LocalizationManager::new(i18n_resource_dir).unwrap_or_else(|e| { - error!("Failed to initialize localization manager: {}", e); - // Create a fallback manager with a temporary directory - LocalizationManager::new(&std::env::temp_dir().join("notedeck_i18n_fallback")) - .expect("Failed to create fallback localization manager") - }), - ); - let i18n = LocalizationContext::new(localization_manager); + let i18n = Localization::new(); // Initialize global i18n context - crate::i18n::init_global_i18n(i18n.clone()); + //crate::i18n::init_global_i18n(i18n.clone()); Self { ndb, @@ -289,7 +279,7 @@ impl Notedeck { zaps: &mut self.zaps, frame_history: &mut self.frame_history, job_pool: &mut self.job_pool, - i18n: &self.i18n, + i18n: &mut self.i18n, } } diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -1,5 +1,5 @@ use crate::{ - account::accounts::Accounts, frame_history::FrameHistory, i18n::LocalizationContext, + account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization, wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler, UnknownIds, }; @@ -25,5 +25,5 @@ pub struct AppContext<'a> { pub zaps: &'a mut Zaps, pub frame_history: &'a mut FrameHistory, pub job_pool: &'a mut JobPool, - pub i18n: &'a LocalizationContext, + pub i18n: &'a mut Localization, } diff --git a/crates/notedeck/src/i18n/error.rs b/crates/notedeck/src/i18n/error.rs @@ -0,0 +1,24 @@ +use super::IntlKeyBuf; +use unic_langid::LanguageIdentifier; + +/// App related errors +#[derive(thiserror::Error, Debug)] +pub enum IntlError { + #[error("message not found: {0}")] + NotFound(IntlKeyBuf), + + #[error("message has no value: {0}")] + NoValue(IntlKeyBuf), + + #[error("Locale({0}) parse error: {1}")] + LocaleParse(LanguageIdentifier, String), + + #[error("locale not available: {0}")] + LocaleNotAvailable(LanguageIdentifier), + + #[error("FTL for '{0}' is not available")] + NoFtl(LanguageIdentifier), + + #[error("Bundle for '{0}' is not available")] + NoBundle(LanguageIdentifier), +} diff --git a/crates/notedeck/src/i18n/key.rs b/crates/notedeck/src/i18n/key.rs @@ -0,0 +1,47 @@ +use std::fmt; + +/// An owned key used to lookup i18n translations. Mostly used for errors +#[derive(Eq, PartialEq, Clone, Debug)] +pub struct IntlKeyBuf(String); + +/// A key used to lookup i18n translations +#[derive(Eq, PartialEq, Clone, Copy, Debug)] +pub struct IntlKey<'a>(&'a str); + +impl fmt::Display for IntlKey<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // Use `self.number` to refer to each positional data point. + write!(f, "{}", self.0) + } +} + +impl fmt::Display for IntlKeyBuf { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + // Use `self.number` to refer to each positional data point. + write!(f, "{}", &self.0) + } +} + +impl IntlKeyBuf { + pub fn new(string: impl Into<String>) -> Self { + IntlKeyBuf(string.into()) + } + + pub fn borrow<'a>(&'a self) -> IntlKey<'a> { + IntlKey::new(&self.0) + } +} + +impl<'a> IntlKey<'a> { + pub fn new(string: &'a str) -> IntlKey<'a> { + IntlKey(string) + } + + pub fn to_owned(&self) -> IntlKeyBuf { + IntlKeyBuf::new(self.0) + } + + pub fn as_str(&self) -> &'a str { + self.0 + } +} diff --git a/crates/notedeck/src/i18n/manager.rs b/crates/notedeck/src/i18n/manager.rs @@ -1,35 +1,55 @@ -use fluent::FluentArgs; -use fluent::{FluentBundle, FluentResource}; +use super::{IntlError, IntlKey, IntlKeyBuf}; +use fluent::{FluentArgs, FluentBundle, FluentResource}; use fluent_langneg::negotiate_languages; +use std::borrow::Cow; use std::collections::HashMap; -use std::path::Path; -use std::sync::{Arc, RwLock}; -use unic_langid::LanguageIdentifier; +use unic_langid::{langid, LanguageIdentifier}; + +const EN_XA: LanguageIdentifier = langid!("en-XA"); +const EN_US: LanguageIdentifier = langid!("en-US"); +const NUM_FTLS: usize = 2; + +struct StaticBundle { + identifier: LanguageIdentifier, + ftl: &'static str, +} + +const FTLS: [StaticBundle; NUM_FTLS] = [ + StaticBundle { + identifier: EN_XA, + ftl: include_str!("../../../../assets/translations/en-XA/main.ftl"), + }, + StaticBundle { + identifier: EN_US, + ftl: include_str!("../../../../assets/translations/en-US/main.ftl"), + }, +]; + +type Bundle = FluentBundle<FluentResource>; /// Manages localization resources and provides localized strings -pub struct LocalizationManager { +pub struct Localization { /// Current locale - current_locale: RwLock<LanguageIdentifier>, + current_locale: LanguageIdentifier, /// Available locales available_locales: Vec<LanguageIdentifier>, /// Fallback locale fallback_locale: LanguageIdentifier, - /// Resource directory path - resource_dir: std::path::PathBuf, - /// Cached parsed FluentResource per locale - resource_cache: RwLock<HashMap<LanguageIdentifier, Arc<FluentResource>>>, + /// Cached string results per locale (only for strings without arguments) - string_cache: RwLock<HashMap<LanguageIdentifier, HashMap<String, String>>>, + string_cache: HashMap<LanguageIdentifier, HashMap<String, String>>, + /// Cached normalized keys + normalized_key_cache: HashMap<String, IntlKeyBuf>, + /// Bundles + bundles: HashMap<LanguageIdentifier, Bundle>, } -impl LocalizationManager { - /// Creates a new LocalizationManager with the specified resource directory - pub fn new(resource_dir: &Path) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> { +impl Default for Localization { + fn default() -> Self { // Default to English (US) - let default_locale: LanguageIdentifier = "en-US" - .parse() - .map_err(|e| format!("Locale parse error: {e:?}"))?; + let default_locale = EN_US; let fallback_locale = default_locale.clone(); + let mut current_locale = default_locale.clone(); // Check if pseudolocale is enabled via environment variable let enable_pseudolocale = std::env::var("NOTEDECK_PSEUDOLOCALE").is_ok(); @@ -39,200 +59,243 @@ impl LocalizationManager { // Add en-XA if pseudolocale is enabled if enable_pseudolocale { - let pseudolocale: LanguageIdentifier = "en-XA" - .parse() - .map_err(|e| format!("Pseudolocale parse error: {e:?}"))?; - available_locales.push(pseudolocale); + available_locales.push(EN_XA.clone()); + current_locale = EN_XA.clone(); tracing::info!( "Pseudolocale (en-XA) enabled via NOTEDECK_PSEUDOLOCALE environment variable" ); } - Ok(Self { - current_locale: RwLock::new(default_locale), + Self { + current_locale, available_locales, fallback_locale, - resource_dir: resource_dir.to_path_buf(), - resource_cache: RwLock::new(HashMap::new()), - string_cache: RwLock::new(HashMap::new()), - }) + normalized_key_cache: HashMap::new(), + string_cache: HashMap::new(), + bundles: HashMap::new(), + } + } +} + +pub enum StringCacheResult<'a> { + Hit(Cow<'a, str>), + NeedsInsert(IntlKeyBuf, String), +} + +impl Localization { + /// Creates a new Localization with the specified resource directory + pub fn new() -> Self { + Localization::default() } /// Gets a localized string by its ID - pub fn get_string(&self, id: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { - tracing::debug!( - "Getting string '{}' for locale '{}'", - id, - self.get_current_locale()? - ); - let result = self.get_string_with_args(id, None); - if let Err(ref e) = result { - tracing::error!("Failed to get string '{}': {}", id, e); + pub fn get_string(&mut self, id: IntlKey<'_>) -> Result<String, IntlError> { + self.get_cached_string(id, None) + } + + /// Load a fluent bundle given a language identifier. Only looks in the static + /// ftl files baked into the binary + fn load_bundle(lang: &LanguageIdentifier) -> Result<Bundle, IntlError> { + for ftl in &FTLS { + if &ftl.identifier == lang { + let mut bundle = FluentBundle::new(vec![lang.to_owned()]); + let resource = FluentResource::try_new(ftl.ftl.to_string()); + match resource { + Err((resource, errors)) => { + for error in errors { + tracing::error!("load_bundle ({lang}): {error}"); + } + + tracing::warn!("load_bundle ({}: loading bundle with errors", lang); + if let Err(errs) = bundle.add_resource(resource) { + for err in errs { + tracing::error!("adding resource: {err}"); + } + } + } + + Ok(resource) => { + tracing::info!("loaded {} bundle OK!", lang); + if let Err(errs) = bundle.add_resource(resource) { + for err in errs { + tracing::error!("adding resource 2: {err}"); + } + } + } + } + + return Ok(bundle); + } } - result + + // no static ftl for this LanguageIdentifier + Err(IntlError::NoFtl(lang.to_owned())) } - /// Loads and caches a parsed FluentResource for the given locale - fn load_resource_for_locale( - &self, - locale: &LanguageIdentifier, - ) -> Result<Arc<FluentResource>, Box<dyn std::error::Error + Send + Sync>> { - // Construct the path using the stored resource directory - let expected_path = self.resource_dir.join(format!("{locale}/main.ftl")); + fn get_bundle<'a>(&'a self, lang: &LanguageIdentifier) -> &'a Bundle { + self.bundles + .get(lang) + .expect("make sure to call ensure_bundle!") + } - // Try to open the file directly - if let Err(e) = std::fs::File::open(&expected_path) { - tracing::error!( - "Direct file open failed: {} ({})", - expected_path.display(), - e - ); - return Err(format!("Failed to open FTL file: {e}").into()); + fn has_bundle(&self, lang: &LanguageIdentifier) -> bool { + self.bundles.contains_key(lang) + } + + fn try_load_bundle(&mut self, lang: &LanguageIdentifier) -> Result<(), IntlError> { + self.bundles + .insert(lang.to_owned(), Self::load_bundle(lang)?); + Ok(()) + } + + pub fn normalized_ftl_key(&mut self, key: &str, comment: &str) -> IntlKeyBuf { + match self.get_ftl_key(key) { + Some(intl_key) => intl_key, + None => { + self.insert_ftl_key(key, comment); + self.get_ftl_key(key).unwrap() + } } + } - // Load the FTL file directly instead of using ResourceManager - let ftl_string = std::fs::read_to_string(&expected_path) - .map_err(|e| format!("Failed to read FTL file: {e}"))?; + fn get_ftl_key(&self, cache_key: &str) -> Option<IntlKeyBuf> { + self.normalized_key_cache.get(cache_key).cloned() + } + + fn insert_ftl_key(&mut self, cache_key: &str, comment: &str) { + let mut result = fixup_key(cache_key); + + // Ensure the key starts with a letter (Fluent requirement) + if result.is_empty() || !result.chars().next().unwrap().is_ascii_alphabetic() { + result = format!("k_{result}"); + } - // Parse the FTL content - let resource = FluentResource::try_new(ftl_string) - .map_err(|e| format!("Failed to parse FTL content: {e:?}"))?; + // If we have a comment, append a hash of it to reduce collisions + let hash_str = format!("_{}", simple_hash(comment)); + result.push_str(&hash_str); tracing::debug!( - "Loaded and cached parsed FluentResource for locale: {}", - locale + "normalize_ftl_key: original='{}', final='{}'", + cache_key, + result ); - Ok(Arc::new(resource)) - } - - /// Gets cached parsed FluentResource for the current locale, loading it if necessary - fn get_cached_resource( - &self, - ) -> Result<Arc<FluentResource>, Box<dyn std::error::Error + Send + Sync>> { - let locale = self - .current_locale - .read() - .map_err(|e| format!("Lock error: {e}"))?; - - // Try to get from cache first - { - let cache = self - .resource_cache - .read() - .map_err(|e| format!("Cache lock error: {e}"))?; - if let Some(resource) = cache.get(&locale) { - tracing::debug!("Using cached parsed FluentResource for locale: {}", locale); - return Ok(resource.clone()); + + self.normalized_key_cache + .insert(cache_key.to_owned(), IntlKeyBuf::new(result)); + } + + fn get_cached_string_no_args<'key>( + &'key self, + lang: &LanguageIdentifier, + id: IntlKey<'key>, + ) -> Result<Cow<'key, str>, IntlError> { + // Try to get from string cache first + if let Some(locale_cache) = self.string_cache.get(lang) { + if let Some(cached_string) = locale_cache.get(id.as_str()) { + /* + tracing::trace!( + "Using cached string result for '{}' in locale: {}", + id, + &lang + ); + */ + + return Ok(Cow::Borrowed(cached_string)); } } - // Not in cache, load and cache it - let resource = self.load_resource_for_locale(&locale)?; - - // Store in cache - { - let mut cache = self - .resource_cache - .write() - .map_err(|e| format!("Cache lock error: {e}"))?; - cache.insert(locale.clone(), resource.clone()); - tracing::debug!("Cached parsed FluentResource for locale: {}", locale); + Err(IntlError::NotFound(id.to_owned())) + } + + fn ensure_bundle(&mut self) -> Result<(), IntlError> { + let locale = self.current_locale.clone(); + if !self.has_bundle(&locale) { + match self.try_load_bundle(&locale) { + Err(err) => { + tracing::warn!( + "tried to load bundle {} but failed with '{err}'. using fallback {}", + &locale, + &self.fallback_locale + ); + self.try_load_bundle(&locale) + .expect("failed to load fallback bundle!?"); + Ok(()) + } + + Ok(()) => Ok(()), + } + } else { + Ok(()) + } + } + + fn get_current_bundle(&self) -> &Bundle { + if self.has_bundle(&self.current_locale) { + return self.get_bundle(&self.current_locale); } - Ok(resource) + self.get_bundle(&self.fallback_locale) } /// Gets cached string result, or formats it and caches the result - fn get_cached_string( - &self, - id: &str, + pub fn get_cached_string( + &mut self, + id: IntlKey<'_>, args: Option<&FluentArgs>, - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { - let locale = self - .current_locale - .read() - .map_err(|e| format!("Lock error: {e}"))?; + ) -> Result<String, IntlError> { + self.ensure_bundle()?; - // Only cache simple strings without arguments - // For strings with arguments, we can't cache the final result since args may vary if args.is_none() { - // Try to get from string cache first - { - let cache = self - .string_cache - .read() - .map_err(|e| format!("String cache lock error: {e}"))?; - if let Some(locale_cache) = cache.get(&locale) { - if let Some(cached_string) = locale_cache.get(id) { - tracing::debug!( - "Using cached string result for '{}' in locale: {}", - id, - locale - ); - return Ok(cached_string.clone()); - } - } + if let Ok(result) = self.get_cached_string_no_args(&self.current_locale, id) { + return Ok(result.to_string()); } } - // Not in cache or has arguments, format it using cached resource - let resource = self.get_cached_resource()?; - - // Create a bundle for this request (not cached due to thread-safety issues) - let mut bundle = FluentBundle::new(vec![locale.clone()]); - bundle - .add_resource(resource.as_ref()) - .map_err(|e| format!("Failed to add resource to bundle: {e:?}"))?; + let result = { + let bundle = self.get_current_bundle(); - let message = bundle - .get_message(id) - .ok_or_else(|| format!("Message not found: {id}"))?; + let message = bundle + .get_message(id.as_str()) + .ok_or_else(|| IntlError::NotFound(id.to_owned()))?; - let pattern = message - .value() - .ok_or_else(|| format!("Message has no value: {id}"))?; + let pattern = message + .value() + .ok_or_else(|| IntlError::NoValue(id.to_owned()))?; - // Format the message - let mut errors = Vec::new(); - let result = bundle.format_pattern(pattern, args, &mut errors); + let mut errors = Vec::with_capacity(0); + let result = bundle.format_pattern(pattern, args, &mut errors); - if !errors.is_empty() { - tracing::warn!("Localization errors for {}: {:?}", id, errors); - } + if !errors.is_empty() { + tracing::warn!("Localization errors for {}: {:?}", id, &errors); + } - let result_string = result.into_owned(); + result.to_string() + }; // Only cache simple strings without arguments // This prevents caching issues when the same message ID is used with different arguments if args.is_none() { - let mut cache = self - .string_cache - .write() - .map_err(|e| format!("String cache lock error: {e}"))?; - let locale_cache = cache.entry(locale.clone()).or_insert_with(HashMap::new); - locale_cache.insert(id.to_string(), result_string.clone()); - tracing::debug!("Cached string result for '{}' in locale: {}", id, locale); + self.cache_string(self.current_locale.clone(), id, result.as_str()); + tracing::debug!( + "Cached string result for '{}' in locale: {}", + id, + &self.current_locale + ); } else { - tracing::debug!("Not caching string '{}' due to arguments", id); + tracing::trace!("Not caching string '{}' due to arguments", id); } - Ok(result_string) + Ok(result) } - /// Gets a localized string by its ID with optional arguments - pub fn get_string_with_args( - &self, - id: &str, - args: Option<&FluentArgs>, - ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { - self.get_cached_string(id, args) + pub fn cache_string<'a>(&mut self, locale: LanguageIdentifier, id: IntlKey<'a>, result: &str) { + tracing::debug!("Cached string result for '{}' in locale: {}", id, &locale); + let locale_cache = self.string_cache.entry(locale).or_default(); + locale_cache.insert(id.to_owned().to_string(), result.to_owned()); } /// Sets the current locale - pub fn set_locale( - &self, - locale: LanguageIdentifier, - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + pub fn set_locale(&mut self, locale: LanguageIdentifier) -> Result<(), IntlError> { tracing::info!("Attempting to set locale to: {}", locale); tracing::info!("Available locales: {:?}", self.available_locales); @@ -243,56 +306,37 @@ impl LocalizationManager { locale, self.available_locales ); - return Err(format!("Locale {locale} is not available").into()); + return Err(IntlError::LocaleNotAvailable(locale)); } - let mut current = self - .current_locale - .write() - .map_err(|e| format!("Lock error: {e}"))?; - tracing::info!("Switching locale from {} to {locale}", *current); - *current = locale.clone(); - tracing::info!("Successfully set locale to: {locale}"); + tracing::info!( + "Switching locale from {} to {}", + &self.current_locale, + &locale + ); + self.current_locale = locale; // Clear caches when locale changes since they are locale-specific - let mut string_cache = self - .string_cache - .write() - .map_err(|e| format!("String cache lock error: {e}"))?; - string_cache.clear(); + self.string_cache.clear(); tracing::debug!("String cache cleared due to locale change"); Ok(()) } /// Clears the parsed FluentResource cache (useful for development when FTL files change) - pub fn clear_cache(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - let mut cache = self - .resource_cache - .write() - .map_err(|e| format!("Cache lock error: {e}"))?; - cache.clear(); - tracing::info!("Parsed FluentResource cache cleared"); - - let mut string_cache = self - .string_cache - .write() - .map_err(|e| format!("String cache lock error: {e}"))?; - string_cache.clear(); - tracing::info!("String result cache cleared"); + pub fn clear_cache(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + self.bundles.clear(); + tracing::debug!("Parsed FluentResource cache cleared"); + + self.string_cache.clear(); + tracing::debug!("String result cache cleared"); Ok(()) } /// Gets the current locale - pub fn get_current_locale( - &self, - ) -> Result<LanguageIdentifier, Box<dyn std::error::Error + Send + Sync>> { - let current = self - .current_locale - .read() - .map_err(|e| format!("Lock error: {e}"))?; - Ok(current.clone()) + pub fn get_current_locale(&self) -> &LanguageIdentifier { + &self.current_locale } /// Gets all available locales @@ -307,38 +351,24 @@ impl LocalizationManager { /// Gets cache statistics for monitoring performance pub fn get_cache_stats(&self) -> Result<CacheStats, Box<dyn std::error::Error + Send + Sync>> { - let resource_cache = self - .resource_cache - .read() - .map_err(|e| format!("Cache lock error: {e}"))?; - let string_cache = self - .string_cache - .read() - .map_err(|e| format!("String cache lock error: {e}"))?; - let mut total_strings = 0; - for locale_cache in string_cache.values() { + for locale_cache in self.string_cache.values() { total_strings += locale_cache.len(); } Ok(CacheStats { - resource_cache_size: resource_cache.len(), + resource_cache_size: self.bundles.len(), string_cache_size: total_strings, - cached_locales: resource_cache.keys().cloned().collect(), + cached_locales: self.bundles.keys().cloned().collect(), }) } /// Limits the string cache size to prevent memory growth pub fn limit_string_cache_size( - &self, + &mut self, max_strings_per_locale: usize, ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - let mut string_cache = self - .string_cache - .write() - .map_err(|e| format!("String cache lock error: {e}"))?; - - for locale_cache in string_cache.values_mut() { + for locale_cache in self.string_cache.values_mut() { if locale_cache.len() > max_strings_per_locale { // Remove oldest entries (simple approach: just clear and let it rebuild) // In a more sophisticated implementation, you might use an LRU cache @@ -365,96 +395,6 @@ impl LocalizationManager { } } -/// Context for sharing localization across the application -#[derive(Clone)] -pub struct LocalizationContext { - /// The localization manager - manager: Arc<LocalizationManager>, -} - -impl LocalizationContext { - /// Creates a new LocalizationContext - pub fn new(manager: Arc<LocalizationManager>) -> Self { - let context = Self { manager }; - - // Auto-switch to pseudolocale if environment variable is set - if std::env::var("NOTEDECK_PSEUDOLOCALE").is_ok() { - tracing::info!("NOTEDECK_PSEUDOLOCALE environment variable detected"); - if let Ok(pseudolocale) = "en-XA".parse::<LanguageIdentifier>() { - tracing::info!("Attempting to switch to pseudolocale: {}", pseudolocale); - if let Err(e) = context.set_locale(pseudolocale) { - tracing::warn!("Failed to switch to pseudolocale: {}", e); - } else { - tracing::info!("Automatically switched to pseudolocale (en-XA)"); - } - } else { - tracing::error!("Failed to parse en-XA as LanguageIdentifier"); - } - } else { - tracing::info!("NOTEDECK_PSEUDOLOCALE environment variable not set"); - } - - context - } - - /// Gets a localized string by its ID - pub fn get_string(&self, id: &str) -> Option<String> { - self.manager.get_string(id).ok() - } - - /// Gets a localized string by its ID with optional arguments - pub fn get_string_with_args(&self, id: &str, args: Option<&FluentArgs>) -> String { - self.manager - .get_string_with_args(id, args) - .unwrap_or_else(|_| format!("[MISSING: {id}]")) - } - - /// Sets the current locale - pub fn set_locale( - &self, - locale: LanguageIdentifier, - ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - self.manager.set_locale(locale) - } - - /// Gets the current locale - pub fn get_current_locale( - &self, - ) -> Result<LanguageIdentifier, Box<dyn std::error::Error + Send + Sync>> { - self.manager.get_current_locale() - } - - /// Clears the resource cache (useful for development when FTL files change) - pub fn clear_cache(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - self.manager.clear_cache() - } - - /// Gets the underlying manager - pub fn manager(&self) -> &Arc<LocalizationManager> { - &self.manager - } -} - -/// Trait for objects that can be localized -pub trait Localizable { - /// Gets a localized string by its ID - fn get_localized_string(&self, id: &str) -> String; - - /// Gets a localized string by its ID with optional arguments - fn get_localized_string_with_args(&self, id: &str, args: Option<&FluentArgs>) -> String; -} - -impl Localizable for LocalizationContext { - fn get_localized_string(&self, id: &str) -> String { - self.get_string(id) - .unwrap_or_else(|| format!("[MISSING: {id}]")) - } - - fn get_localized_string_with_args(&self, id: &str, args: Option<&FluentArgs>) -> String { - self.get_string_with_args(id, args) - } -} - /// Statistics about cache usage #[derive(Debug, Clone)] pub struct CacheStats { @@ -468,266 +408,162 @@ mod tests { use super::*; #[test] - fn test_localization_manager_creation() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let manager = LocalizationManager::new(&temp_dir); - assert!(manager.is_ok()); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); - } - - #[test] fn test_locale_management() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test2"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let i18n = Localization::default(); // Test default locale - let current = manager.get_current_locale().unwrap(); + let current = i18n.get_current_locale(); assert_eq!(current.to_string(), "en-US"); // Test available locales - let available = manager.get_available_locales(); + let available = i18n.get_available_locales(); assert_eq!(available.len(), 1); assert_eq!(available[0].to_string(), "en-US"); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_ftl_caching() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test3"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "test_key = Test Value\nanother_key = Another Value"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let mut i18n = Localization::default(); // First call should load and cache the FTL content - let result1 = manager.get_string("test_key"); + let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow()); assert!(result1.is_ok()); assert_eq!(result1.as_ref().unwrap(), "Test Value"); // Second call should use cached FTL content - let result2 = manager.get_string("test_key"); + let result2 = i18n.get_string(IntlKeyBuf::new("test_key").borrow()); assert!(result2.is_ok()); assert_eq!(result2.unwrap(), "Test Value"); // Test another key from the same FTL content - let result3 = manager.get_string("another_key"); + let result3 = i18n.get_string(IntlKeyBuf::new("another_key").borrow()); assert!(result3.is_ok()); assert_eq!(result3.unwrap(), "Another Value"); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_cache_clearing() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test4"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "test_key = Test Value"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let mut i18n = Localization::default(); // Load and cache the FTL content - let result1 = manager.get_string("test_key"); + let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow()); assert!(result1.is_ok()); // Clear the cache - let clear_result = manager.clear_cache(); + let clear_result = i18n.clear_cache(); assert!(clear_result.is_ok()); // Should still work after clearing cache (will reload) - let result2 = manager.get_string("test_key"); + let result2 = i18n.get_string(IntlKeyBuf::new("test_key").borrow()); assert!(result2.is_ok()); assert_eq!(result2.unwrap(), "Test Value"); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_context_caching() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test5"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "test_key = Test Value"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = Arc::new(LocalizationManager::new(&temp_dir).unwrap()); - let context = LocalizationContext::new(manager); + let mut i18n = Localization::default(); // Debug: check what the normalized key should be - let normalized_key = crate::i18n::normalize_ftl_key("test_key", None); + let normalized_key = i18n.normalized_ftl_key("test_key", "comment"); println!("Normalized key: '{}'", normalized_key); // First call should load and cache the FTL content - let result1 = context.get_string("test_key"); + let result1 = i18n.get_string(normalized_key.borrow()); println!("First result: {:?}", result1); - assert!(result1.is_some()); + assert!(result1.is_ok()); assert_eq!(result1.unwrap(), "Test Value"); // Second call should use cached FTL content - let result2 = context.get_string("test_key"); - assert!(result2.is_some()); + let result2 = i18n.get_string(normalized_key.borrow()); + assert!(result2.is_ok()); assert_eq!(result2.unwrap(), "Test Value"); // Test cache clearing through context - let clear_result = context.clear_cache(); + let clear_result = i18n.clear_cache(); assert!(clear_result.is_ok()); // Should still work after clearing cache - let result3 = context.get_string("test_key"); - assert!(result3.is_some()); + let result3 = i18n.get_string(normalized_key.borrow()); + assert!(result3.is_ok()); assert_eq!(result3.unwrap(), "Test Value"); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_bundle_caching() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test6"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "test_key = Test Value\nanother_key = Another Value"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let mut i18n = Localization::default(); // First call should create bundle and cache the resource - let result1 = manager.get_string("test_key"); + let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow()); assert!(result1.is_ok()); assert_eq!(result1.unwrap(), "Test Value"); // Second call should use cached resource but create new bundle - let result2 = manager.get_string("another_key"); + let result2 = i18n.get_string(IntlKeyBuf::new("another_key").borrow()); assert!(result2.is_ok()); assert_eq!(result2.unwrap(), "Another Value"); // Check cache stats - let stats = manager.get_cache_stats().unwrap(); + let stats = i18n.get_cache_stats().unwrap(); assert_eq!(stats.resource_cache_size, 1); assert_eq!(stats.string_cache_size, 2); // Both strings should be cached - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_string_caching() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test7"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "test_key = Test Value"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let mut i18n = Localization::default(); + let key = i18n.normalized_ftl_key("test_key", "comment"); // First call should format and cache the string - let result1 = manager.get_string("test_key"); + let result1 = i18n.get_string(key.borrow()); assert!(result1.is_ok()); assert_eq!(result1.unwrap(), "Test Value"); // Second call should use cached string - let result2 = manager.get_string("test_key"); + let result2 = i18n.get_string(key.borrow()); assert!(result2.is_ok()); assert_eq!(result2.unwrap(), "Test Value"); // Check cache stats - let stats = manager.get_cache_stats().unwrap(); + let stats = i18n.get_cache_stats().unwrap(); assert_eq!(stats.string_cache_size, 1); - - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_cache_clearing_on_locale_change() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test8"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create test FTL files for two locales - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - std::fs::write(en_us_dir.join("main.ftl"), "test_key = Test Value").unwrap(); - - let en_xa_dir = temp_dir.join("en-XA"); - std::fs::create_dir_all(&en_xa_dir).unwrap(); - std::fs::write(en_xa_dir.join("main.ftl"), "test_key = Test Value XA").unwrap(); - // Enable pseudolocale for this test std::env::set_var("NOTEDECK_PSEUDOLOCALE", "1"); - let manager = LocalizationManager::new(&temp_dir).unwrap(); - - // Load some strings in en-US - let result1 = manager.get_string("test_key"); - assert!(result1.is_ok()); + let mut i18n = Localization::default(); // Check that caches are populated - let stats1 = manager.get_cache_stats().unwrap(); + let stats1 = i18n.get_cache_stats().unwrap(); assert!(stats1.resource_cache_size > 0); assert!(stats1.string_cache_size > 0); // Switch to en-XA - let en_xa: LanguageIdentifier = "en-XA".parse().unwrap(); - manager.set_locale(en_xa).unwrap(); + let en_xa: LanguageIdentifier = langid!("en-XA"); + i18n.set_locale(en_xa).unwrap(); // Check that string cache is cleared (resource cache remains for both locales) - let stats2 = manager.get_cache_stats().unwrap(); + let stats2 = i18n.get_cache_stats().unwrap(); assert_eq!(stats2.string_cache_size, 0); // Cleanup std::env::remove_var("NOTEDECK_PSEUDOLOCALE"); - std::fs::remove_dir_all(&temp_dir).unwrap(); } #[test] fn test_string_caching_with_arguments() { - let temp_dir = std::env::temp_dir().join("notedeck_i18n_test9"); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Create a test FTL file with a message that takes arguments - let en_us_dir = temp_dir.join("en-US"); - std::fs::create_dir_all(&en_us_dir).unwrap(); - let ftl_content = "welcome_message = Welcome {$name}!"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content).unwrap(); - - let manager = LocalizationManager::new(&temp_dir).unwrap(); + let mut manager = Localization::default(); // First call with arguments should not be cached let mut args = fluent::FluentArgs::new(); args.set("name", "Alice"); - let result1 = manager.get_string_with_args("welcome_message", Some(&args)); - assert!(result1.is_ok()); - // Note: Fluent may add bidirectional text control characters, so we check contains - let result1_str = result1.unwrap(); - assert!(result1_str.contains("Alice")); + let key = IntlKeyBuf::new("welcome_message"); + let result1 = manager + .get_cached_string(key.borrow(), Some(&args)) + .unwrap(); + assert!(result1.contains("Alice")); // Check that it's not in the string cache let stats1 = manager.get_cache_stats().unwrap(); @@ -736,7 +572,7 @@ mod tests { // Second call with different arguments should work correctly let mut args2 = fluent::FluentArgs::new(); args2.set("name", "Bob"); - let result2 = manager.get_string_with_args("welcome_message", Some(&args2)); + let result2 = manager.get_cached_string(key.borrow(), Some(&args2)); assert!(result2.is_ok()); let result2_str = result2.unwrap(); assert!(result2_str.contains("Bob")); @@ -745,22 +581,35 @@ mod tests { let stats2 = manager.get_cache_stats().unwrap(); assert_eq!(stats2.string_cache_size, 0); - // Test a simple string without arguments - should be cached - let ftl_content_simple = "simple_message = Hello World"; - std::fs::write(en_us_dir.join("main.ftl"), ftl_content_simple).unwrap(); - // Clear cache to start fresh manager.clear_cache().unwrap(); - let result3 = manager.get_string("simple_message"); + let result3 = manager.get_string(key.borrow()); assert!(result3.is_ok()); assert_eq!(result3.unwrap(), "Hello World"); // Check that simple string is cached let stats3 = manager.get_cache_stats().unwrap(); assert_eq!(stats3.string_cache_size, 1); + } +} - // Cleanup - std::fs::remove_dir_all(&temp_dir).unwrap(); +/// Replace each invalid character with exactly one underscore +/// This matches the behavior of the Python extraction script +pub fn fixup_key(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' => out.push(ch), + _ => out.push('_'), // always push + } } + let trimmed = out.trim_matches('_'); + trimmed.to_owned() +} + +fn simple_hash(s: &str) -> String { + let digest = md5::compute(s.as_bytes()); + // Take the first 2 bytes and convert to 4 hex characters + format!("{:02x}{:02x}", digest[0], digest[1]) } diff --git a/crates/notedeck/src/i18n/mod.rs b/crates/notedeck/src/i18n/mod.rs @@ -4,104 +4,22 @@ //! It handles loading translation files, managing locales, and providing //! localized strings throughout the application. +mod error; +mod key; pub mod manager; +pub use error::IntlError; +pub use key::{IntlKey, IntlKeyBuf}; + pub use manager::CacheStats; -pub use manager::LocalizationContext; -pub use manager::LocalizationManager; +pub use manager::Localization; +pub use manager::StringCacheResult; /// Re-export commonly used types for convenience pub use fluent::FluentArgs; pub use fluent::FluentValue; pub use unic_langid::LanguageIdentifier; -use md5; -use once_cell::sync::OnceCell; -use regex::Regex; -use std::collections::HashMap; -use std::sync::Arc; -use std::sync::Mutex; -use tracing::info; - -/// Global localization manager for easy access from anywhere -static GLOBAL_I18N: OnceCell<Arc<LocalizationManager>> = OnceCell::new(); - -/// Cache for normalized FTL keys to avoid repeated normalization -static NORMALIZED_KEY_CACHE: OnceCell<Mutex<HashMap<String, String>>> = OnceCell::new(); - -/// Initialize the global localization context -pub fn init_global_i18n(context: LocalizationContext) { - info!("Initializing global i18n context"); - let _ = GLOBAL_I18N.set(context.manager().clone()); - - // Initialize the normalized key cache - let _ = NORMALIZED_KEY_CACHE.set(Mutex::new(HashMap::new())); - - info!("Global i18n context initialized successfully"); -} - -/// Get the global localization manager -pub fn get_global_i18n() -> Option<Arc<LocalizationManager>> { - GLOBAL_I18N.get().cloned() -} - -fn simple_hash(s: &str) -> String { - let digest = md5::compute(s.as_bytes()); - // Take the first 2 bytes and convert to 4 hex characters - format!("{:02x}{:02x}", digest[0], digest[1]) -} - -pub fn normalize_ftl_key(key: &str, comment: Option<&str>) -> String { - // Try to get from cache first - let cache_key = if let Some(comment) = comment { - format!("{key}:{comment}") - } else { - key.to_string() - }; - - if let Some(cache) = NORMALIZED_KEY_CACHE.get() { - if let Ok(cache) = cache.lock() { - if let Some(cached) = cache.get(&cache_key) { - return cached.clone(); - } - } - } - - // Replace each invalid character with exactly one underscore - // This matches the behavior of the Python extraction script - let re = Regex::new(r"[^a-zA-Z0-9_-]").unwrap(); - let mut result = re.replace_all(key, "_").to_string(); - - // Remove leading/trailing underscores - result = result.trim_matches('_').to_string(); - - // Ensure the key starts with a letter (Fluent requirement) - if result.is_empty() || !result.chars().next().unwrap().is_ascii_alphabetic() { - result = format!("k_{result}"); - } - - // If we have a comment, append a hash of it to reduce collisions - if let Some(comment) = comment { - let hash_str = format!("_{}", simple_hash(comment)); - result.push_str(&hash_str); - } - - // Cache the result - if let Some(cache) = NORMALIZED_KEY_CACHE.get() { - if let Ok(mut cache) = cache.lock() { - cache.insert(cache_key, result.clone()); - } - } - - tracing::debug!( - "normalize_ftl_key: original='{}', comment='{:?}', final='{}'", - key, - comment, - result - ); - result -} - /// Macro for getting localized strings with format-like syntax /// /// Syntax: tr!("message", comment) @@ -114,53 +32,36 @@ pub fn normalize_ftl_key(key: &str, comment: Option<&str>) -> String { /// All placeholders must be named and start with a letter (a-zA-Z). #[macro_export] macro_rules! tr { - // Simple case: just message and comment - ($message:expr, $comment:expr) => { + ($i18n:expr, $message:expr, $comment:expr) => { { - let norm_key = $crate::i18n::normalize_ftl_key($message, Some($comment)); - if let Some(i18n) = $crate::i18n::get_global_i18n() { - let result = i18n.get_string(&norm_key); - match result { - Ok(ref s) if s != $message => s.clone(), - _ => { - tracing::warn!("FALLBACK: Using key '{}' as string (not found in FTL)", $message); - $message.to_string() - } + let key = $i18n.normalized_ftl_key($message, $comment); + match $i18n.get_string(key.borrow()) { + Ok(r) => r, + Err(_err) => { + $message.to_string() } - } else { - tracing::warn!("FALLBACK: Global i18n not initialized, using key '{}' as string", $message); - $message.to_string() } } }; // Case with named parameters: message, comment, param=value, ... - ($message:expr, $comment:expr, $($param:ident = $value:expr),*) => { + ($i18n:expr, $message:expr, $comment:expr, $($param:ident = $value:expr),*) => { { - let norm_key = $crate::i18n::normalize_ftl_key($message, Some($comment)); - if let Some(i18n) = $crate::i18n::get_global_i18n() { - let mut args = $crate::i18n::FluentArgs::new(); - $( - args.set(stringify!($param), $value); - )* - match i18n.get_string_with_args(&norm_key, Some(&args)) { - Ok(s) => s, - Err(_) => { - // Fallback: replace placeholders with values - let mut result = $message.to_string(); - $( - result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string()); - )* - result - } + let key = $i18n.normalized_ftl_key($message, $comment); + let mut args = $crate::i18n::FluentArgs::new(); + $( + args.set(stringify!($param), $value); + )* + match $i18n.get_cached_string(key.borrow(), Some(&args)) { + Ok(r) => r, + Err(_) => { + // Fallback: replace placeholders with values + let mut result = $message.to_string(); + $( + result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string()); + )* + result } - } else { - // Fallback: replace placeholders with values - let mut result = $message.to_string(); - $( - result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string()); - )* - result } } }; @@ -177,42 +78,27 @@ macro_rules! tr { #[macro_export] macro_rules! tr_plural { // With named parameters - ($one:expr, $other:expr, $comment:expr, $count:expr, $($param:ident = $value:expr),*) => {{ - let norm_key = $crate::i18n::normalize_ftl_key($other, Some($comment)); - if let Some(i18n) = $crate::i18n::get_global_i18n() { - let mut args = $crate::i18n::FluentArgs::new(); - args.set("count", $count); - $(args.set(stringify!($param), $value);)* - match i18n.get_string_with_args(&norm_key, Some(&args)) { - Ok(s) => s, - Err(_) => { - // Fallback: use simple pluralization - if $count == 1 { - let mut result = $one.to_string(); - $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* - result = result.replace("{count}", &$count.to_string()); - result - } else { - let mut result = $other.to_string(); - $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* - result = result.replace("{count}", &$count.to_string()); - result - } + ($i18n:expr, $one:expr, $other:expr, $comment:expr, $count:expr, $($param:ident = $value:expr),*) => {{ + let norm_key = $i18n.normalized_ftl_key($other, $comment); + let mut args = $crate::i18n::FluentArgs::new(); + args.set("count", $count); + $(args.set(stringify!($param), $value);)* + match $i18n.get_cached_string(norm_key.borrow(), Some(&args)) { + Ok(s) => s, + Err(_) => { + // Fallback: use simple pluralization + if $count == 1 { + let mut result = $one.to_string(); + $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* + result = result.replace("{count}", &$count.to_string()); + result + } else { + let mut result = $other.to_string(); + $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* + result = result.replace("{count}", &$count.to_string()); + result } } - } else { - // Fallback: use simple pluralization - if $count == 1 { - let mut result = $one.to_string(); - $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* - result = result.replace("{count}", &$count.to_string()); - result - } else { - let mut result = $other.to_string(); - $(result = result.replace(&format!("{{{}}}", stringify!($param)), &$value.to_string());)* - result = result.replace("{count}", &$count.to_string()); - result - } } }}; // Without named parameters diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -45,11 +45,7 @@ pub use context::AppContext; pub use error::{show_one_error_message, Error, FilterError, ZapError}; pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use fonts::NamedFontFamily; -pub use i18n::manager::Localizable; -pub use i18n::{ - CacheStats, FluentArgs, FluentValue, LanguageIdentifier, LocalizationContext, - LocalizationManager, -}; +pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization}; pub use imgcache::{ Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache, MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache, @@ -89,5 +85,3 @@ pub use enostr; pub use nostrdb; pub use zaps::Zaps; - -pub use crate::i18n::{get_global_i18n, init_global_i18n}; diff --git a/crates/notedeck/src/note/mod.rs b/crates/notedeck/src/note/mod.rs @@ -6,6 +6,7 @@ pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; use crate::Accounts; use crate::JobPool; +use crate::Localization; use crate::UnknownIds; use crate::{notecache::NoteCache, zaps::Zaps, Images}; use enostr::{NoteId, RelayPool}; @@ -19,6 +20,7 @@ use std::fmt; pub struct NoteContext<'d> { pub ndb: &'d Ndb, pub accounts: &'d Accounts, + pub i18n: &'d mut Localization, pub img_cache: &'d mut Images, pub note_cache: &'d mut NoteCache, pub zaps: &'d mut Zaps, diff --git a/crates/notedeck/src/notecache.rs b/crates/notedeck/src/notecache.rs @@ -1,7 +1,5 @@ -use crate::{time_ago_since, TimeCached}; use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf}; use std::collections::HashMap; -use std::time::Duration; #[derive(Default)] pub struct NoteCache { @@ -32,7 +30,7 @@ impl NoteCache { #[derive(Clone)] pub struct CachedNote { - reltime: TimeCached<String>, + //reltime: TimeCached<String>, pub client: Option<String>, pub reply: NoteReplyBuf, } @@ -41,22 +39,25 @@ impl CachedNote { pub fn new(note: &Note) -> Self { use crate::note::event_tag; + /* let created_at = note.created_at(); let reltime = TimeCached::new( Duration::from_secs(1), - Box::new(move || time_ago_since(created_at)), + Box::new(move || time_ago_since(i18n, created_at)), ); + */ let reply = NoteReply::new(note.tags()).to_owned(); let client = event_tag(note, "client"); CachedNote { client: client.map(|c| c.to_string()), - reltime, + // reltime, reply, } } + /* pub fn reltime_str_mut(&mut self) -> &str { self.reltime.get_mut() } @@ -64,4 +65,5 @@ impl CachedNote { pub fn reltime_str(&self) -> Option<&str> { self.reltime.get().map(|x| x.as_str()) } + */ } diff --git a/crates/notedeck/src/time.rs b/crates/notedeck/src/time.rs @@ -1,4 +1,4 @@ -use crate::tr; +use crate::{tr, Localization}; use std::time::{SystemTime, UNIX_EPOCH}; // Time duration constants in seconds @@ -18,7 +18,7 @@ const MAX_SECONDS_FOR_WEEKS: u64 = ONE_MONTH_IN_SECONDS - 1; const MAX_SECONDS_FOR_MONTHS: u64 = ONE_YEAR_IN_SECONDS - 1; /// Calculate relative time between two timestamps -fn time_ago_between(timestamp: u64, now: u64) -> String { +fn time_ago_between(i18n: &mut Localization, timestamp: u64, now: u64) -> String { // Determine if the timestamp is in the future or the past let duration = if now >= timestamp { now.saturating_sub(timestamp) @@ -28,36 +28,48 @@ fn time_ago_between(timestamp: u64, now: u64) -> String { let time_str = match duration { 0..=2 => tr!( + i18n, "now", "Relative time for very recent events (less than 3 seconds)" ), - 3..=MAX_SECONDS => tr!("{count}s", "Relative time in seconds", count = duration), + 3..=MAX_SECONDS => tr!( + i18n, + "{count}s", + "Relative time in seconds", + count = duration + ), ONE_MINUTE_IN_SECONDS..=MAX_SECONDS_FOR_MINUTES => tr!( + i18n, "{count}m", "Relative time in minutes", count = duration / ONE_MINUTE_IN_SECONDS ), ONE_HOUR_IN_SECONDS..=MAX_SECONDS_FOR_HOURS => tr!( + i18n, "{count}h", "Relative time in hours", count = duration / ONE_HOUR_IN_SECONDS ), ONE_DAY_IN_SECONDS..=MAX_SECONDS_FOR_DAYS => tr!( + i18n, "{count}d", "Relative time in days", count = duration / ONE_DAY_IN_SECONDS ), ONE_WEEK_IN_SECONDS..=MAX_SECONDS_FOR_WEEKS => tr!( + i18n, "{count}w", "Relative time in weeks", count = duration / ONE_WEEK_IN_SECONDS ), ONE_MONTH_IN_SECONDS..=MAX_SECONDS_FOR_MONTHS => tr!( + i18n, "{count}mo", "Relative time in months", count = duration / ONE_MONTH_IN_SECONDS ), _ => tr!( + i18n, "{count}y", "Relative time in years", count = duration / ONE_YEAR_IN_SECONDS @@ -65,19 +77,19 @@ fn time_ago_between(timestamp: u64, now: u64) -> String { }; if timestamp > now { - format!("+{}", time_str) + format!("+{time_str}") } else { time_str } } -pub fn time_ago_since(timestamp: u64) -> String { +pub fn time_ago_since(i18n: &mut Localization, timestamp: u64) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); - time_ago_between(timestamp, now) + time_ago_between(i18n, timestamp, now) } #[cfg(test)] @@ -95,9 +107,10 @@ mod tests { #[test] fn test_now_condition() { let now = get_current_timestamp(); + let mut intl = Localization::default(); // Test 0 seconds ago - let result = time_ago_between(now, now); + let result = time_ago_between(&mut intl, now, now); assert_eq!( result, "now", "Expected 'now' for 0 seconds, got: {}", @@ -105,7 +118,7 @@ mod tests { ); // Test 1 second ago - let result = time_ago_between(now - 1, now); + let result = time_ago_between(&mut intl, now - 1, now); assert_eq!( result, "now", "Expected 'now' for 1 second, got: {}", @@ -113,7 +126,7 @@ mod tests { ); // Test 2 seconds ago - let result = time_ago_between(now - 2, now); + let result = time_ago_between(&mut intl, now - 2, now); assert_eq!( result, "now", "Expected 'now' for 2 seconds, got: {}", @@ -124,13 +137,14 @@ mod tests { #[test] fn test_seconds_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 3 seconds ago - let result = time_ago_between(now - 3, now); + let result = time_ago_between(&mut i18n, now - 3, now); assert_eq!(result, "3s", "Expected '3s' for 3 seconds, got: {}", result); // Test 30 seconds ago - let result = time_ago_between(now - 30, now); + let result = time_ago_between(&mut i18n, now - 30, now); assert_eq!( result, "30s", "Expected '30s' for 30 seconds, got: {}", @@ -138,7 +152,7 @@ mod tests { ); // Test 59 seconds ago (max for seconds) - let result = time_ago_between(now - 59, now); + let result = time_ago_between(&mut i18n, now - 59, now); assert_eq!( result, "59s", "Expected '59s' for 59 seconds, got: {}", @@ -149,13 +163,14 @@ mod tests { #[test] fn test_minutes_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 minute ago - let result = time_ago_between(now - ONE_MINUTE_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_MINUTE_IN_SECONDS, now); assert_eq!(result, "1m", "Expected '1m' for 1 minute, got: {}", result); // Test 30 minutes ago - let result = time_ago_between(now - 30 * ONE_MINUTE_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 30 * ONE_MINUTE_IN_SECONDS, now); assert_eq!( result, "30m", "Expected '30m' for 30 minutes, got: {}", @@ -163,7 +178,7 @@ mod tests { ); // Test 59 minutes ago (max for minutes) - let result = time_ago_between(now - 59 * ONE_MINUTE_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 59 * ONE_MINUTE_IN_SECONDS, now); assert_eq!( result, "59m", "Expected '59m' for 59 minutes, got: {}", @@ -174,13 +189,14 @@ mod tests { #[test] fn test_hours_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 hour ago - let result = time_ago_between(now - ONE_HOUR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_HOUR_IN_SECONDS, now); assert_eq!(result, "1h", "Expected '1h' for 1 hour, got: {}", result); // Test 12 hours ago - let result = time_ago_between(now - 12 * ONE_HOUR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 12 * ONE_HOUR_IN_SECONDS, now); assert_eq!( result, "12h", "Expected '12h' for 12 hours, got: {}", @@ -188,7 +204,7 @@ mod tests { ); // Test 23 hours ago (max for hours) - let result = time_ago_between(now - 23 * ONE_HOUR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 23 * ONE_HOUR_IN_SECONDS, now); assert_eq!( result, "23h", "Expected '23h' for 23 hours, got: {}", @@ -199,43 +215,46 @@ mod tests { #[test] fn test_days_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 day ago - let result = time_ago_between(now - ONE_DAY_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_DAY_IN_SECONDS, now); assert_eq!(result, "1d", "Expected '1d' for 1 day, got: {}", result); // Test 3 days ago - let result = time_ago_between(now - 3 * ONE_DAY_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 3 * ONE_DAY_IN_SECONDS, now); assert_eq!(result, "3d", "Expected '3d' for 3 days, got: {}", result); // Test 6 days ago (max for days, before weeks) - let result = time_ago_between(now - 6 * ONE_DAY_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 6 * ONE_DAY_IN_SECONDS, now); assert_eq!(result, "6d", "Expected '6d' for 6 days, got: {}", result); } #[test] fn test_weeks_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 week ago - let result = time_ago_between(now - ONE_WEEK_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_WEEK_IN_SECONDS, now); assert_eq!(result, "1w", "Expected '1w' for 1 week, got: {}", result); // Test 4 weeks ago - let result = time_ago_between(now - 4 * ONE_WEEK_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 4 * ONE_WEEK_IN_SECONDS, now); assert_eq!(result, "4w", "Expected '4w' for 4 weeks, got: {}", result); } #[test] fn test_months_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 month ago - let result = time_ago_between(now - ONE_MONTH_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_MONTH_IN_SECONDS, now); assert_eq!(result, "1mo", "Expected '1mo' for 1 month, got: {}", result); // Test 11 months ago (max for months, before years) - let result = time_ago_between(now - 11 * ONE_MONTH_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 11 * ONE_MONTH_IN_SECONDS, now); assert_eq!( result, "11mo", "Expected '11mo' for 11 months, got: {}", @@ -246,17 +265,18 @@ mod tests { #[test] fn test_years_condition() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 year ago - let result = time_ago_between(now - ONE_YEAR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - ONE_YEAR_IN_SECONDS, now); assert_eq!(result, "1y", "Expected '1y' for 1 year, got: {}", result); // Test 5 years ago - let result = time_ago_between(now - 5 * ONE_YEAR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 5 * ONE_YEAR_IN_SECONDS, now); assert_eq!(result, "5y", "Expected '5y' for 5 years, got: {}", result); // Test 10 years ago (reduced from 100 to avoid overflow) - let result = time_ago_between(now - 10 * ONE_YEAR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now - 10 * ONE_YEAR_IN_SECONDS, now); assert_eq!( result, "10y", "Expected '10y' for 10 years, got: {}", @@ -267,9 +287,10 @@ mod tests { #[test] fn test_future_timestamps() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test 1 minute in the future - let result = time_ago_between(now + ONE_MINUTE_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now + ONE_MINUTE_IN_SECONDS, now); assert_eq!( result, "+1m", "Expected '+1m' for 1 minute in future, got: {}", @@ -277,7 +298,7 @@ mod tests { ); // Test 1 hour in the future - let result = time_ago_between(now + ONE_HOUR_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now + ONE_HOUR_IN_SECONDS, now); assert_eq!( result, "+1h", "Expected '+1h' for 1 hour in future, got: {}", @@ -285,7 +306,7 @@ mod tests { ); // Test 1 day in the future - let result = time_ago_between(now + ONE_DAY_IN_SECONDS, now); + let result = time_ago_between(&mut i18n, now + ONE_DAY_IN_SECONDS, now); assert_eq!( result, "+1d", "Expected '+1d' for 1 day in future, got: {}", @@ -296,9 +317,10 @@ mod tests { #[test] fn test_boundary_conditions() { let now = get_current_timestamp(); + let mut i18n = Localization::default(); // Test boundary between seconds and minutes - let result = time_ago_between(now - 60, now); + let result = time_ago_between(&mut i18n, now - 60, now); assert_eq!( result, "1m", "Expected '1m' for exactly 60 seconds, got: {}", @@ -306,7 +328,7 @@ mod tests { ); // Test boundary between minutes and hours - let result = time_ago_between(now - 3600, now); + let result = time_ago_between(&mut i18n, now - 3600, now); assert_eq!( result, "1h", "Expected '1h' for exactly 3600 seconds, got: {}", @@ -314,7 +336,7 @@ mod tests { ); // Test boundary between hours and days - let result = time_ago_between(now - 86400, now); + let result = time_ago_between(&mut i18n, now - 86400, now); assert_eq!( result, "1d", "Expected '1d' for exactly 86400 seconds, got: {}", diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -5,7 +5,9 @@ use crate::app::NotedeckApp; use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget}; use egui_extras::{Size, StripBuilder}; use nostrdb::{ProfileRecord, Transaction}; -use notedeck::{tr, App, AppAction, AppContext, NotedeckTextStyle, UserAccount, WalletType}; +use notedeck::{ + tr, App, AppAction, AppContext, Localization, NotedeckTextStyle, UserAccount, WalletType, +}; use notedeck_columns::{ column::SelectionResult, timeline::kind::ListKind, timeline::TimelineKind, Damus, }; @@ -59,14 +61,17 @@ pub enum ChromePanelAction { } impl ChromePanelAction { - fn columns_switch(ctx: &AppContext, chrome: &mut Chrome, kind: &TimelineKind) { + fn columns_switch(ctx: &mut AppContext, chrome: &mut Chrome, kind: &TimelineKind) { chrome.switch_to_columns(); let Some(columns_app) = chrome.get_columns_app() else { return; }; - if let Some(active_columns) = columns_app.decks_cache.active_columns_mut(ctx.accounts) { + if let Some(active_columns) = columns_app + .decks_cache + .active_columns_mut(ctx.i18n, ctx.accounts) + { match active_columns.select_by_kind(kind) { SelectionResult::NewSelection(_index) => { // great! no need to go to top yet @@ -85,13 +90,14 @@ impl ChromePanelAction { } } - fn columns_navigate(ctx: &AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) { + fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) { chrome.switch_to_columns(); - if let Some(c) = chrome - .get_columns_app() - .and_then(|columns| columns.decks_cache.selected_column_mut(ctx.accounts)) - { + if let Some(c) = chrome.get_columns_app().and_then(|columns| { + columns + .decks_cache + .selected_column_mut(ctx.i18n, ctx.accounts) + }) { if c.router().routes().iter().any(|r| r == &route) { // return if we are already routing to accounts c.router_mut().go_back(); @@ -102,7 +108,7 @@ impl ChromePanelAction { }; } - fn process(&self, ctx: &AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { + fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { match self { Self::SaveTheme(theme) => { ui.ctx().options_mut(|o| { @@ -244,7 +250,7 @@ impl Chrome { .vertical(|mut vstrip| { vstrip.cell(|ui| { _ = ui.vertical_centered(|ui| { - self.topdown_sidebar(ui); + self.topdown_sidebar(ui, app_ctx.i18n); }) }); vstrip.cell(|ui| { @@ -401,7 +407,7 @@ impl Chrome { } } - fn topdown_sidebar(&mut self, ui: &mut egui::Ui) { + fn topdown_sidebar(&mut self, ui: &mut egui::Ui, i18n: &mut Localization) { // macos needs a bit of space to make room for window // minimize/close buttons if cfg!(target_os = "macos") { @@ -417,7 +423,7 @@ impl Chrome { } ui.add_space(4.0); - ui.add(milestone_name()); + ui.add(milestone_name(i18n)); ui.add_space(16.0); //let dark_mode = ui.ctx().style().visuals.dark_mode; { @@ -451,7 +457,7 @@ impl notedeck::App for Chrome { } } -fn milestone_name() -> impl Widget { +fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { |ui: &mut egui::Ui| -> egui::Response { ui.vertical_centered(|ui| { let font = egui::FontId::new( @@ -460,13 +466,14 @@ fn milestone_name() -> impl Widget { ); ui.add( Label::new( - RichText::new(tr!("BETA", "Beta version label")) + RichText::new(tr!(i18n, "BETA", "Beta version label")) .color(ui.style().visuals.noninteractive().fg_stroke.color) .font(font), ) .selectable(false), ) .on_hover_text(tr!( + i18n, "Notedeck is a beta product. Expect bugs and contact us when you run into issues.", "Beta product warning message" )) @@ -657,7 +664,7 @@ fn chrome_handle_app_action( let cols = columns .decks_cache - .active_columns_mut(ctx.accounts) + .active_columns_mut(ctx.i18n, ctx.accounts) .unwrap(); let m_action = notedeck_columns::actionbar::execute_and_process_note_action( note_action, @@ -721,6 +728,7 @@ fn bottomup_sidebar( .add(Button::new("☀").frame(false)) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(tr!( + ctx.i18n, "Switch to light mode", "Hover text for light mode toggle button" )); @@ -735,6 +743,7 @@ fn bottomup_sidebar( .add(Button::new("🌙").frame(false)) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(tr!( + ctx.i18n, "Switch to dark mode", "Hover text for dark mode toggle button" )); diff --git a/crates/notedeck_columns/src/accounts/mod.rs b/crates/notedeck_columns/src/accounts/mod.rs @@ -1,7 +1,7 @@ use enostr::{FullKeypair, Pubkey}; use nostrdb::{Ndb, Transaction}; -use notedeck::{Accounts, AppContext, SingleUnkIdAction, UnknownIds}; +use notedeck::{Accounts, AppContext, Localization, SingleUnkIdAction, UnknownIds}; use crate::app::get_active_columns_mut; use crate::decks::DecksCache; @@ -72,23 +72,34 @@ pub fn render_accounts_route( route: AccountsRoute, ) -> AddAccountAction { let resp = match route { - AccountsRoute::Accounts => { - AccountsView::new(app_ctx.ndb, app_ctx.accounts, app_ctx.img_cache) + AccountsRoute::Accounts => AccountsView::new( + app_ctx.ndb, + app_ctx.accounts, + app_ctx.img_cache, + app_ctx.i18n, + ) + .ui(ui) + .inner + .map(AccountsRouteResponse::Accounts), + + AccountsRoute::AddAccount => { + AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n) .ui(ui) .inner - .map(AccountsRouteResponse::Accounts) + .map(AccountsRouteResponse::AddAccount) } - - AccountsRoute::AddAccount => AccountLoginView::new(login_state, app_ctx.clipboard) - .ui(ui) - .inner - .map(AccountsRouteResponse::AddAccount), }; if let Some(resp) = resp { match resp { AccountsRouteResponse::Accounts(response) => { - let action = process_accounts_view_response(app_ctx.accounts, decks, col, response); + let action = process_accounts_view_response( + app_ctx.i18n, + app_ctx.accounts, + decks, + col, + response, + ); AddAccountAction { accounts_action: action, unk_id_action: SingleUnkIdAction::no_action(), @@ -98,7 +109,7 @@ pub fn render_accounts_route( let action = process_login_view_response(app_ctx, timeline_cache, decks, col, response); *login_state = Default::default(); - let router = get_active_columns_mut(app_ctx.accounts, decks) + let router = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, decks) .column_mut(col) .router_mut(); router.go_back(); @@ -114,12 +125,13 @@ pub fn render_accounts_route( } pub fn process_accounts_view_response( + i18n: &mut Localization, accounts: &mut Accounts, decks: &mut DecksCache, col: usize, response: AccountsViewResponse, ) -> Option<AccountsAction> { - let router = get_active_columns_mut(accounts, decks) + let router = get_active_columns_mut(i18n, accounts, decks) .column_mut(col) .router_mut(); let mut action = None; diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -284,7 +284,7 @@ impl NewNotes { let timeline = if let Some(profile) = timeline_cache.get_mut(&self.id) { profile } else { - error!("NewNotes: could not get timeline for key {}", self.id); + error!("NewNotes: could not get timeline for key {:?}", self.id); return; }; diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -20,7 +20,7 @@ use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPo use nostrdb::Transaction; use notedeck::{ tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, - UnknownIds, + Localization, UnknownIds, }; use notedeck_ui::{jobs::JobsCache, NoteOptions}; use std::collections::{BTreeSet, HashMap}; @@ -92,7 +92,8 @@ fn try_process_event( app_ctx: &mut AppContext<'_>, ctx: &egui::Context, ) -> Result<()> { - let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache); + let current_columns = + get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache); ctx.input(|i| handle_key_events(i, current_columns)); let ctx2 = ctx.clone(); @@ -187,7 +188,9 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con app_ctx.img_cache.urls.cache.handle_io(); if damus.columns(app_ctx.accounts).columns().is_empty() { - damus.columns_mut(app_ctx.accounts).new_column_picker(); + damus + .columns_mut(app_ctx.i18n, app_ctx.accounts) + .new_column_picker(); } match damus.state { @@ -262,7 +265,7 @@ fn handle_eose( tl } else { error!( - "timeline uid:{} not found for FetchingContactList", + "timeline uid:{:?} not found for FetchingContactList", timeline_uid ); return Ok(()); @@ -427,9 +430,9 @@ impl Damus { } } - columns_to_decks_cache(columns, account) + 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) + crate::storage::load_decks_cache(ctx.path, ctx.ndb, &mut timeline_cache, ctx.i18n) { info!( "DecksCache: loading from disk {}", @@ -495,8 +498,8 @@ impl Damus { self.options.insert(AppOptions::ScrollToTop) } - pub fn columns_mut(&mut self, accounts: &Accounts) -> &mut Columns { - get_active_columns_mut(accounts, &mut self.decks_cache) + pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns { + get_active_columns_mut(i18n, accounts, &mut self.decks_cache) } pub fn columns(&self, accounts: &Accounts) -> &Columns { @@ -512,7 +515,8 @@ impl Damus { } pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { - let decks_cache = DecksCache::default(); + let mut i18n = Localization::default(); + let decks_cache = DecksCache::default_decks_cache(&mut i18n); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache); @@ -567,7 +571,7 @@ fn render_damus_mobile( let rect = ui.available_rect_before_wrap(); let mut app_action: Option<AppAction> = None; - let active_col = app.columns_mut(app_ctx.accounts).selected as usize; + 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, @@ -623,6 +627,7 @@ fn hovering_post_button( &mut app.decks_cache, app_ctx.accounts, SidePanelAction::ComposeNote, + app_ctx.i18n, ); } } @@ -715,9 +720,12 @@ fn timelines_view( .horizontal(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); - let side_panel = - DesktopSidePanel::new(ctx.accounts.get_selected_account(), &app.decks_cache) - .show(ui); + let side_panel = DesktopSidePanel::new( + ctx.accounts.get_selected_account(), + &app.decks_cache, + ctx.i18n, + ) + .show(ui); if let Some(side_panel) = side_panel { if side_panel.response.clicked() || side_panel.response.secondary_clicked() { @@ -725,6 +733,7 @@ fn timelines_view( &mut app.decks_cache, ctx.accounts, side_panel.action, + ctx.i18n, ) { side_panel_action = Some(action); } @@ -833,27 +842,32 @@ pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a De } pub fn get_active_columns_mut<'a>( + i18n: &mut Localization, accounts: &Accounts, decks_cache: &'a mut DecksCache, ) -> &'a mut Columns { - get_decks_mut(accounts, decks_cache) + get_decks_mut(i18n, accounts, decks_cache) .active_mut() .columns_mut() } -pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks { - decks_cache.decks_mut(accounts.selected_account_pubkey()) +pub fn get_decks_mut<'a>( + i18n: &mut Localization, + accounts: &Accounts, + decks_cache: &'a mut DecksCache, +) -> &'a mut Decks { + decks_cache.decks_mut(i18n, accounts.selected_account_pubkey()) } -fn columns_to_decks_cache(cols: Columns, key: &[u8; 32]) -> DecksCache { +fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[u8; 32]) -> DecksCache { let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default(); let decks = Decks::new(crate::decks::Deck::new_with_columns( - crate::decks::Deck::default().icon, - tr!("My Deck", "Title for the user's deck"), + crate::decks::Deck::default_icon(), + tr!(i18n, "My Deck", "Title for the user's deck"), cols, )); let account = Pubkey::new(*key); account_to_decks.insert(account, decks); - DecksCache::new(account_to_decks) + DecksCache::new(account_to_decks, i18n) } diff --git a/crates/notedeck_columns/src/decks.rs b/crates/notedeck_columns/src/decks.rs @@ -2,7 +2,7 @@ use std::collections::{hash_map::ValuesMut, HashMap}; use enostr::{Pubkey, RelayPool}; use nostrdb::Transaction; -use notedeck::{tr, AppContext, FALLBACK_PUBKEY}; +use notedeck::{tr, AppContext, Localization, FALLBACK_PUBKEY}; use tracing::{error, info}; use crate::{ @@ -21,18 +21,20 @@ pub struct DecksCache { fallback_pubkey: Pubkey, } -impl Default for DecksCache { - fn default() -> Self { +impl DecksCache { + pub fn default_decks_cache(i18n: &mut Localization) -> Self { let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default(); - account_to_decks.insert(FALLBACK_PUBKEY(), Decks::default()); - DecksCache::new(account_to_decks) + account_to_decks.insert(FALLBACK_PUBKEY(), Decks::default_decks(i18n)); + DecksCache::new(account_to_decks, i18n) } -} -impl DecksCache { /// Gets the first column in the currently active user's active deck - pub fn selected_column_mut(&mut self, accounts: &notedeck::Accounts) -> Option<&mut Column> { - self.active_columns_mut(accounts) + pub fn selected_column_mut( + &mut self, + i18n: &mut Localization, + accounts: &notedeck::Accounts, + ) -> Option<&mut Column> { + self.active_columns_mut(i18n, accounts) .and_then(|ad| ad.selected_mut()) } @@ -45,10 +47,14 @@ impl DecksCache { } /// Gets a mutable reference to the active columns - pub fn active_columns_mut(&mut self, accounts: &notedeck::Accounts) -> Option<&mut Columns> { + pub fn active_columns_mut( + &mut self, + i18n: &mut Localization, + accounts: &notedeck::Accounts, + ) -> Option<&mut Columns> { let account = accounts.get_selected_account(); - self.decks_mut(&account.key.pubkey) + self.decks_mut(i18n, &account.key.pubkey) .active_deck_mut() .map(|ad| ad.columns_mut()) } @@ -62,9 +68,11 @@ impl DecksCache { .map(|ad| ad.columns()) } - pub fn new(mut account_to_decks: HashMap<Pubkey, Decks>) -> Self { + pub fn new(mut account_to_decks: HashMap<Pubkey, Decks>, i18n: &mut Localization) -> Self { let fallback_pubkey = FALLBACK_PUBKEY(); - account_to_decks.entry(fallback_pubkey).or_default(); + account_to_decks + .entry(fallback_pubkey) + .or_insert_with(|| Decks::default_decks(i18n)); Self { account_to_decks, @@ -79,7 +87,7 @@ impl DecksCache { fallback_pubkey, demo_decks(fallback_pubkey, timeline_cache, ctx), ); - DecksCache::new(account_to_decks) + DecksCache::new(account_to_decks, ctx.i18n) } pub fn decks(&self, key: &Pubkey) -> &Decks { @@ -88,8 +96,10 @@ impl DecksCache { .unwrap_or_else(|| self.fallback()) } - pub fn decks_mut(&mut self, key: &Pubkey) -> &mut Decks { - self.account_to_decks.entry(*key).or_default() + pub fn decks_mut(&mut self, i18n: &mut Localization, key: &Pubkey) -> &mut Decks { + self.account_to_decks + .entry(*key) + .or_insert_with(|| Decks::default_decks(i18n)) } pub fn fallback(&self) -> &Decks { @@ -110,7 +120,7 @@ impl DecksCache { timeline_cache: &mut TimelineCache, pubkey: Pubkey, ) { - let mut decks = Decks::default(); + let mut decks = Decks::default_decks(ctx.i18n); // add home and notifications for new accounts add_demo_columns( @@ -157,6 +167,7 @@ impl DecksCache { pub fn remove( &mut self, + i18n: &mut Localization, key: &Pubkey, timeline_cache: &mut TimelineCache, ndb: &mut nostrdb::Ndb, @@ -171,7 +182,7 @@ impl DecksCache { if !self.account_to_decks.contains_key(&self.fallback_pubkey) { self.account_to_decks - .insert(self.fallback_pubkey, Decks::default()); + .insert(self.fallback_pubkey, Decks::default_decks(i18n)); } } @@ -194,13 +205,11 @@ pub struct Decks { decks: Vec<Deck>, } -impl Default for Decks { - fn default() -> Self { - Decks::new(Deck::default()) +impl Decks { + pub fn default_decks(i18n: &mut Localization) -> Self { + Decks::new(Deck::default_deck(i18n)) } -} -impl Decks { pub fn new(deck: Deck) -> Self { let decks = vec![deck]; @@ -381,24 +390,22 @@ pub struct Deck { columns: Columns, } -impl Default for Deck { - fn default() -> Self { +impl Deck { + pub fn default_icon() -> char { + '🇩' + } + + fn default_deck(i18n: &mut Localization) -> Self { let columns = Columns::default(); Self { columns, icon: Deck::default_icon(), - name: Deck::default_name().to_string(), + name: Deck::default_name(i18n).to_string(), } } -} - -impl Deck { - pub fn default_icon() -> char { - '🇩' - } - pub fn default_name() -> String { - tr!("Default Deck", "Name of the default deck feed") + pub fn default_name(i18n: &mut Localization) -> String { + tr!(i18n, "Default Deck", "Name of the default deck feed") } pub fn new(icon: char, name: String) -> Self { @@ -482,7 +489,7 @@ pub fn demo_decks( Deck { icon: Deck::default_icon(), - name: Deck::default_name().to_string(), + name: Deck::default_name(ctx.i18n).to_string(), columns, } }; diff --git a/crates/notedeck_columns/src/login_manager.rs b/crates/notedeck_columns/src/login_manager.rs @@ -2,7 +2,7 @@ use crate::key_parsing::perform_key_retrieval; use crate::key_parsing::AcquireKeyError; use egui::{TextBuffer, TextEdit}; use enostr::Keypair; -use notedeck::tr; +use notedeck::{tr, Localization}; use poll_promise::Promise; /// The state data for acquiring a nostr key @@ -24,7 +24,7 @@ impl<'a> AcquireKeyState { /// Get the textedit for the UI without exposing the key variable pub fn get_acquire_textedit( &'a mut self, - textedit_closure: fn(&'a mut dyn TextBuffer) -> TextEdit<'a>, + textedit_closure: impl FnOnce(&'a mut dyn TextBuffer) -> TextEdit<'a>, ) -> TextEdit<'a> { textedit_closure(&mut self.desired_key) } @@ -106,7 +106,7 @@ impl<'a> AcquireKeyState { self.should_create_new } - pub fn loading_and_error_ui(&mut self, ui: &mut egui::Ui) { + pub fn loading_and_error_ui(&mut self, ui: &mut egui::Ui, i18n: &mut Localization) { ui.add_space(8.0); ui.vertical_centered(|ui| { @@ -116,7 +116,7 @@ impl<'a> AcquireKeyState { }); if let Some(err) = self.check_for_error() { - show_error(ui, err); + show_error(ui, i18n, err); } ui.add_space(8.0); @@ -131,12 +131,16 @@ impl<'a> AcquireKeyState { } } -fn show_error(ui: &mut egui::Ui, err: &AcquireKeyError) { +fn show_error(ui: &mut egui::Ui, i18n: &mut Localization, err: &AcquireKeyError) { ui.horizontal(|ui| { let error_label = match err { AcquireKeyError::InvalidKey => egui::Label::new( - egui::RichText::new(tr!("Invalid key.", "Error message for invalid key input")) - .color(ui.visuals().error_fg_color), + egui::RichText::new(tr!( + i18n, + "Invalid key.", + "Error message for invalid key input" + )) + .color(ui.visuals().error_fg_color), ), AcquireKeyError::Nip05Failed(e) => { egui::Label::new(egui::RichText::new(e).color(ui.visuals().error_fg_color)) diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -89,7 +89,7 @@ impl SwitchingAction { ui_ctx, ); // pop nav after switch - get_active_columns_mut(ctx.accounts, decks_cache) + get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache) .column_mut(switch_action.source_column) .router_mut() .go_back(); @@ -102,13 +102,13 @@ impl SwitchingAction { break 's; } - decks_cache.remove(to_remove, timeline_cache, ctx.ndb, ctx.pool); + decks_cache.remove(ctx.i18n, to_remove, timeline_cache, ctx.ndb, ctx.pool); } }, SwitchingAction::Columns(columns_action) => match *columns_action { ColumnsAction::Remove(index) => { - let kinds_to_pop = - get_active_columns_mut(ctx.accounts, decks_cache).delete_column(index); + let kinds_to_pop = get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache) + .delete_column(index); for kind in &kinds_to_pop { if let Err(err) = timeline_cache.pop(kind, ctx.ndb, ctx.pool) { error!("error popping timeline: {err}"); @@ -117,15 +117,15 @@ impl SwitchingAction { } ColumnsAction::Switch(from, to) => { - get_active_columns_mut(ctx.accounts, decks_cache).move_col(from, to); + get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache).move_col(from, to); } }, SwitchingAction::Decks(decks_action) => match *decks_action { DecksAction::Switch(index) => { - get_decks_mut(ctx.accounts, decks_cache).set_active(index) + get_decks_mut(ctx.i18n, ctx.accounts, decks_cache).set_active(index) } DecksAction::Removing(index) => { - get_decks_mut(ctx.accounts, decks_cache).remove_deck( + get_decks_mut(ctx.i18n, ctx.accounts, decks_cache).remove_deck( index, timeline_cache, ctx.ndb, @@ -206,10 +206,10 @@ fn process_popup_resp( } if let Some(NavAction::Returned(_)) = action.action { - let column = app.columns_mut(ctx.accounts).column_mut(col); + let column = app.columns_mut(ctx.i18n, ctx.accounts).column_mut(col); column.sheet_router.clear(); } else if let Some(NavAction::Navigating) = action.action { - let column = app.columns_mut(ctx.accounts).column_mut(col); + let column = app.columns_mut(ctx.i18n, ctx.accounts).column_mut(col); column.sheet_router.navigating = false; } @@ -235,7 +235,7 @@ fn process_nav_resp( match action { NavAction::Returned(return_type) => { let r = app - .columns_mut(ctx.accounts) + .columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .pop(); @@ -260,7 +260,10 @@ fn process_nav_resp( } NavAction::Navigated => { - let cur_router = app.columns_mut(ctx.accounts).column_mut(col).router_mut(); + let cur_router = app + .columns_mut(ctx.i18n, ctx.accounts) + .column_mut(col) + .router_mut(); cur_router.navigating = false; if cur_router.is_replacing() { cur_router.remove_previous_routes(); @@ -414,7 +417,7 @@ fn process_render_nav_action( RenderNavAction::Back => Some(RouterAction::GoBack), RenderNavAction::PfpClicked => Some(RouterAction::PfpClicked), RenderNavAction::RemoveColumn => { - let kinds_to_pop = app.columns_mut(ctx.accounts).delete_column(col); + let kinds_to_pop = app.columns_mut(ctx.i18n, ctx.accounts).delete_column(col); for kind in &kinds_to_pop { if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) { @@ -439,7 +442,7 @@ fn process_render_nav_action( crate::actionbar::execute_and_process_note_action( note_action, ctx.ndb, - get_active_columns_mut(ctx.accounts, &mut app.decks_cache), + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache), col, &mut app.timeline_cache, &mut app.threads, @@ -480,7 +483,8 @@ fn process_render_nav_action( }; if let Some(action) = router_action { - let cols = get_active_columns_mut(ctx.accounts, &mut app.decks_cache).column_mut(col); + let cols = + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache).column_mut(col); let router = &mut cols.router; let sheet_router = &mut cols.sheet_router; @@ -511,6 +515,7 @@ fn render_nav_body( unknown_ids: ctx.unknown_ids, clipboard: ctx.clipboard, current_account_has_wallet, + i18n: ctx.i18n, }; match top { Route::Timeline(kind) => { @@ -565,7 +570,7 @@ fn render_nav_body( .accounts_action .map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) } - Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map) + Route::Relays => RelayView::new(ctx.pool, &mut app.view_state.id_string_map, ctx.i18n) .ui(ui) .map(RenderNavAction::RelayAction), Route::Reply(id) => { @@ -573,6 +578,7 @@ fn render_nav_body( txn } else { ui.label(tr!( + note_context.i18n, "Reply to unknown note", "Error message when reply note cannot be found" )); @@ -583,6 +589,7 @@ fn render_nav_body( note } else { ui.label(tr!( + note_context.i18n, "Reply to unknown note", "Error message when reply note cannot be found" )); @@ -623,6 +630,7 @@ fn render_nav_body( note } else { ui.label(tr!( + note_context.i18n, "Quote of unknown note", "Error message when quote note cannot be found" )); @@ -676,15 +684,16 @@ fn render_nav_body( None } Route::Support => { - SupportView::new(&mut app.support).show(ui); + SupportView::new(&mut app.support, ctx.i18n).show(ui); None } Route::Search => { let id = ui.id().with(("search", depth, col)); - let navigating = get_active_columns_mut(ctx.accounts, &mut app.decks_cache) - .column(col) - .router() - .navigating; + let navigating = + get_active_columns_mut(note_context.i18n, ctx.accounts, &mut app.decks_cache) + .column(col) + .router() + .navigating; let search_buffer = app.view_state.searches.entry(id).or_default(); let txn = Transaction::new(ctx.ndb).expect("txn"); @@ -711,13 +720,13 @@ fn render_nav_body( let id = ui.id().with("new-deck"); let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default(); let mut resp = None; - if let Some(config_resp) = ConfigureDeckView::new(new_deck_state).ui(ui) { + if let Some(config_resp) = ConfigureDeckView::new(new_deck_state, ctx.i18n).ui(ui) { let cur_acc = ctx.accounts.selected_account_pubkey(); app.decks_cache .add_deck(*cur_acc, Deck::new(config_resp.icon, config_resp.name)); // set new deck as active - let cur_index = get_decks_mut(ctx.accounts, &mut app.decks_cache) + let cur_index = get_decks_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .decks() .len() - 1; @@ -726,7 +735,7 @@ fn render_nav_body( ))); new_deck_state.clear(); - get_active_columns_mut(ctx.accounts, &mut app.decks_cache) + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .get_first_router() .go_back(); } @@ -734,7 +743,7 @@ fn render_nav_body( } Route::EditDeck(index) => { let mut action = None; - let cur_deck = get_decks_mut(ctx.accounts, &mut app.decks_cache) + let cur_deck = get_decks_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .decks_mut() .get_mut(*index) .expect("index wasn't valid"); @@ -746,7 +755,7 @@ fn render_nav_body( .id_to_deck_state .entry(id) .or_insert_with(|| DeckState::from_deck(cur_deck)); - if let Some(resp) = EditDeckView::new(deck_state).ui(ui) { + if let Some(resp) = EditDeckView::new(deck_state, ctx.i18n).ui(ui) { match resp { EditDeckResponse::Edit(configure_deck_response) => { cur_deck.edit(configure_deck_response); @@ -757,7 +766,7 @@ fn render_nav_body( ))); } } - get_active_columns_mut(ctx.accounts, &mut app.decks_cache) + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .get_first_router() .go_back(); } @@ -778,7 +787,7 @@ fn render_nav_body( return action; }; - if EditProfileView::new(state, ctx.img_cache).ui(ui) { + if EditProfileView::new(ctx.i18n, state, ctx.img_cache).ui(ui) { if let Some(state) = app.view_state.pubkey_to_profile_state.get(kp.pubkey) { action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges( SaveProfileChanges::new(kp.to_full(), state.clone()), @@ -833,7 +842,7 @@ fn render_nav_body( } }; - WalletView::new(state) + WalletView::new(state, ctx.i18n) .ui(ui) .map(RenderNavAction::WalletAction) } @@ -841,6 +850,7 @@ fn render_nav_body( let txn = Transaction::new(ctx.ndb).expect("txn"); let default_msats = get_current_default_msats(ctx.accounts, ctx.global_wallet); CustomZapView::new( + ctx.i18n, ctx.img_cache, ctx.ndb, &txn, @@ -849,7 +859,7 @@ fn render_nav_body( ) .ui(ui) .map(|msats| { - get_active_columns_mut(ctx.accounts, &mut app.decks_cache) + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache) .column_mut(col) .router_mut() .go_back(); @@ -904,9 +914,10 @@ pub fn render_nav( NavUiType::Title => NavTitle::new( ctx.ndb, ctx.img_cache, - get_active_columns_mut(ctx.accounts, &mut app.decks_cache), + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache), &[route.clone()], col, + ctx.i18n, ) .show_move_button(!narrow) .show_delete_button(!narrow) @@ -926,13 +937,13 @@ pub fn render_nav( .clone(), ) .navigating( - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .navigating, ) .returning( - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .returning, @@ -942,9 +953,10 @@ pub fn render_nav( NavUiType::Title => NavTitle::new( ctx.ndb, ctx.img_cache, - get_active_columns_mut(ctx.accounts, &mut app.decks_cache), + get_active_columns_mut(ctx.i18n, ctx.accounts, &mut app.decks_cache), nav.routes(), col, + ctx.i18n, ) .show_move_button(!narrow) .show_delete_button(!narrow) diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs @@ -1,16 +1,10 @@ use enostr::{NoteId, Pubkey}; -use notedeck::{tr, NoteZapTargetOwned, RootNoteIdBuf, WalletType}; -use std::{ - fmt::{self}, - ops::Range, -}; +use notedeck::{tr, Localization, NoteZapTargetOwned, RootNoteIdBuf, WalletType}; +use std::ops::Range; use crate::{ accounts::AccountsRoute, - timeline::{ - kind::{AlgoTimeline, ColumnTitle, ListKind}, - ThreadSelection, TimelineKind, - }, + timeline::{kind::ColumnTitle, ThreadSelection, TimelineKind}, ui::add_column::{AddAlgoRoute, AddColumnRoute}, }; @@ -241,85 +235,104 @@ impl Route { ) } - pub fn title(&self) -> ColumnTitle<'_> { + pub fn title(&self, i18n: &mut Localization) -> ColumnTitle<'_> { match self { - Route::Timeline(kind) => kind.to_title(), + Route::Timeline(kind) => kind.to_title(i18n), Route::Thread(_) => { - ColumnTitle::formatted(tr!("Thread", "Column title for note thread view")) + ColumnTitle::formatted(tr!(i18n, "Thread", "Column title for note thread view")) } Route::Reply(_id) => { - ColumnTitle::formatted(tr!("Reply", "Column title for reply composition")) + ColumnTitle::formatted(tr!(i18n, "Reply", "Column title for reply composition")) } Route::Quote(_id) => { - ColumnTitle::formatted(tr!("Quote", "Column title for quote composition")) + ColumnTitle::formatted(tr!(i18n, "Quote", "Column title for quote composition")) } Route::Relays => { - ColumnTitle::formatted(tr!("Relays", "Column title for relay management")) + ColumnTitle::formatted(tr!(i18n, "Relays", "Column title for relay management")) } Route::Accounts(amr) => match amr { - AccountsRoute::Accounts => { - ColumnTitle::formatted(tr!("Accounts", "Column title for account management")) - } + AccountsRoute::Accounts => ColumnTitle::formatted(tr!( + i18n, + "Accounts", + "Column title for account management" + )), AccountsRoute::AddAccount => ColumnTitle::formatted(tr!( + i18n, "Add Account", "Column title for adding new account" )), }, - Route::ComposeNote => { - ColumnTitle::formatted(tr!("Compose Note", "Column title for note composition")) - } + Route::ComposeNote => ColumnTitle::formatted(tr!( + i18n, + "Compose Note", + "Column title for note composition" + )), Route::AddColumn(c) => match c { - AddColumnRoute::Base => { - ColumnTitle::formatted(tr!("Add Column", "Column title for adding new column")) - } + AddColumnRoute::Base => ColumnTitle::formatted(tr!( + i18n, + "Add Column", + "Column title for adding new column" + )), AddColumnRoute::Algo(r) => match r { AddAlgoRoute::Base => ColumnTitle::formatted(tr!( + i18n, "Add Algo Column", "Column title for adding algorithm column" )), AddAlgoRoute::LastPerPubkey => ColumnTitle::formatted(tr!( + i18n, "Add Last Notes Column", "Column title for adding last notes column" )), }, AddColumnRoute::UndecidedNotification => ColumnTitle::formatted(tr!( + i18n, "Add Notifications Column", "Column title for adding notifications column" )), AddColumnRoute::ExternalNotification => ColumnTitle::formatted(tr!( + i18n, "Add External Notifications Column", "Column title for adding external notifications column" )), AddColumnRoute::Hashtag => ColumnTitle::formatted(tr!( + i18n, "Add Hashtag Column", "Column title for adding hashtag column" )), AddColumnRoute::UndecidedIndividual => ColumnTitle::formatted(tr!( + i18n, "Subscribe to someone's notes", "Column title for subscribing to individual user" )), AddColumnRoute::ExternalIndividual => ColumnTitle::formatted(tr!( + i18n, "Subscribe to someone else's notes", "Column title for subscribing to external user" )), }, Route::Support => { - ColumnTitle::formatted(tr!("Damus Support", "Column title for support page")) + ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page")) } Route::NewDeck => { - ColumnTitle::formatted(tr!("Add Deck", "Column title for adding new deck")) + ColumnTitle::formatted(tr!(i18n, "Add Deck", "Column title for adding new deck")) } Route::EditDeck(_) => { - ColumnTitle::formatted(tr!("Edit Deck", "Column title for editing deck")) + ColumnTitle::formatted(tr!(i18n, "Edit Deck", "Column title for editing deck")) } - Route::EditProfile(_) => { - ColumnTitle::formatted(tr!("Edit Profile", "Column title for profile editing")) + Route::EditProfile(_) => ColumnTitle::formatted(tr!( + i18n, + "Edit Profile", + "Column title for profile editing" + )), + Route::Search => { + ColumnTitle::formatted(tr!(i18n, "Search", "Column title for search page")) } - Route::Search => ColumnTitle::formatted(tr!("Search", "Column title for search page")), Route::Wallet(_) => { - ColumnTitle::formatted(tr!("Wallet", "Column title for wallet management")) + ColumnTitle::formatted(tr!(i18n, "Wallet", "Column title for wallet management")) } Route::CustomizeZapAmount(_) => ColumnTitle::formatted(tr!( + i18n, "Customize Zap Amount", "Column title for zap amount customization" )), @@ -492,12 +505,13 @@ impl<R: Clone> Router<R> { } } +/* impl fmt::Display for Route { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Route::Timeline(kind) => match kind { TimelineKind::List(ListKind::Contact(_pk)) => { - write!(f, "{}", tr!("Home", "Display name for home feed")) + write!(f, "{}", i18n, "Home", "Display name for home feed")) } TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(_))) => { write!( @@ -583,6 +597,7 @@ impl fmt::Display for Route { } } } +*/ #[derive(Clone, Debug)] pub struct SingletonRouter<R: Clone> { diff --git a/crates/notedeck_columns/src/storage/decks.rs b/crates/notedeck_columns/src/storage/decks.rs @@ -13,7 +13,7 @@ use crate::{ Error, }; -use notedeck::{storage, DataPath, DataPathType, Directory}; +use notedeck::{storage, DataPath, DataPathType, Directory, Localization}; use tokenator::{ParseError, TokenParser, TokenWriter}; pub static DECKS_CACHE_FILE: &str = "decks_cache.json"; @@ -22,6 +22,7 @@ pub fn load_decks_cache( path: &DataPath, ndb: &Ndb, timeline_cache: &mut TimelineCache, + i18n: &mut Localization, ) -> Option<DecksCache> { let data_path = path.path(DataPathType::Setting); @@ -40,7 +41,7 @@ pub fn load_decks_cache( serde_json::from_str::<SerializableDecksCache>(&decks_cache_str).ok()?; serializable_decks_cache - .decks_cache(ndb, timeline_cache) + .decks_cache(ndb, timeline_cache, i18n) .ok() } @@ -91,6 +92,7 @@ impl SerializableDecksCache { self, ndb: &Ndb, timeline_cache: &mut TimelineCache, + i18n: &mut Localization, ) -> Result<DecksCache, Error> { let account_to_decks = self .decks_cache @@ -102,7 +104,7 @@ impl SerializableDecksCache { }) .collect::<Result<HashMap<Pubkey, Decks>, Error>>()?; - Ok(DecksCache::new(account_to_decks)) + Ok(DecksCache::new(account_to_decks, i18n)) } } diff --git a/crates/notedeck_columns/src/timeline/kind.rs b/crates/notedeck_columns/src/timeline/kind.rs @@ -6,11 +6,11 @@ use nostrdb::{Ndb, Transaction}; use notedeck::{ contacts::{contacts_filter, hybrid_contacts_filter}, filter::{self, default_limit, default_remote_limit, HybridFilter}, - tr, FilterError, FilterState, NoteCache, RootIdError, RootNoteIdBuf, + tr, FilterError, FilterState, Localization, NoteCache, RootIdError, RootNoteIdBuf, }; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::hash::{Hash, Hasher}; -use std::{borrow::Cow, fmt::Display}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use tracing::{error, warn}; @@ -254,6 +254,7 @@ impl AlgoTimeline { } } +/* impl Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -301,6 +302,7 @@ impl Display for TimelineKind { } } } +*/ impl TimelineKind { pub fn pubkey(&self) -> Option<&Pubkey> { @@ -594,31 +596,32 @@ impl TimelineKind { } } - pub fn to_title(&self) -> ColumnTitle<'_> { + pub fn to_title(&self, i18n: &mut Localization) -> ColumnTitle<'_> { match self { TimelineKind::Search(query) => { ColumnTitle::formatted(format!("Search \"{}\"", query.search)) } TimelineKind::List(list_kind) => match list_kind { ListKind::Contact(_pubkey_source) => { - ColumnTitle::formatted(tr!("Contacts", "Column title for contact lists")) + ColumnTitle::formatted(tr!(i18n, "Contacts", "Column title for contact lists")) } }, TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => match list_kind { ListKind::Contact(_pubkey_source) => ColumnTitle::formatted(tr!( + i18n, "Contacts (last notes)", "Column title for last notes per contact" )), }, TimelineKind::Notifications(_pubkey_source) => { - ColumnTitle::formatted(tr!("Notifications", "Column title for notifications")) + ColumnTitle::formatted(tr!(i18n, "Notifications", "Column title for notifications")) } TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self), TimelineKind::Universe => { - ColumnTitle::formatted(tr!("Universe", "Column title for universe feed")) + ColumnTitle::formatted(tr!(i18n, "Universe", "Column title for universe feed")) } TimelineKind::Generic(_) => { - ColumnTitle::formatted(tr!("Custom", "Column title for custom timelines")) + ColumnTitle::formatted(tr!(i18n, "Custom", "Column title for custom timelines")) } TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.join(" ").to_string()), } diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs @@ -9,8 +9,8 @@ use crate::{ use notedeck::{ contacts::hybrid_contacts_filter, filter::{self, HybridFilter}, - tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, NoteCache, - NoteRef, UnknownIds, + tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, Localization, + NoteCache, NoteRef, UnknownIds, }; use egui_virtual_list::VirtualList; @@ -64,11 +64,15 @@ pub enum ViewFilter { } impl ViewFilter { - pub fn name(&self) -> String { + pub fn name(&self, i18n: &mut Localization) -> String { match self { - ViewFilter::Notes => tr!("Notes", "Filter label for notes only view"), + ViewFilter::Notes => tr!(i18n, "Notes", "Filter label for notes only view"), ViewFilter::NotesAndReplies => { - tr!("Notes & Replies", "Filter label for notes and replies view") + tr!( + i18n, + "Notes & Replies", + "Filter label for notes and replies view" + ) } } } diff --git a/crates/notedeck_columns/src/ui/account_login_view.rs b/crates/notedeck_columns/src/ui/account_login_view.rs @@ -1,12 +1,11 @@ use crate::login_manager::AcquireKeyState; use crate::ui::{Preview, PreviewConfig}; use egui::{ - Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextBuffer, TextEdit, - Vec2, + Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextEdit, Vec2, }; use egui_winit::clipboard::Clipboard; use enostr::Keypair; -use notedeck::{fonts::get_font_size, tr, AppAction, NotedeckTextStyle}; +use notedeck::{fonts::get_font_size, tr, AppAction, Localization, NotedeckTextStyle}; use notedeck_ui::{ app_images, context_menu::{input_context, PasteBehavior}, @@ -15,6 +14,7 @@ use notedeck_ui::{ pub struct AccountLoginView<'a> { manager: &'a mut AcquireKeyState, clipboard: &'a mut Clipboard, + i18n: &'a mut Localization, } pub enum AccountLoginResponse { @@ -23,8 +23,16 @@ pub enum AccountLoginResponse { } impl<'a> AccountLoginView<'a> { - pub fn new(manager: &'a mut AcquireKeyState, clipboard: &'a mut Clipboard) -> Self { - AccountLoginView { manager, clipboard } + pub fn new( + manager: &'a mut AcquireKeyState, + clipboard: &'a mut Clipboard, + i18n: &'a mut Localization, + ) -> Self { + AccountLoginView { + manager, + clipboard, + i18n, + } } pub fn ui(&mut self, ui: &mut egui::Ui) -> InnerResponse<Option<AccountLoginResponse>> { @@ -35,11 +43,11 @@ impl<'a> AccountLoginView<'a> { ui.vertical(|ui| { ui.vertical_centered(|ui| { ui.add_space(32.0); - ui.label(login_title_text()); + ui.label(login_title_text(self.i18n)); }); ui.horizontal(|ui| { - ui.label(login_textedit_info_text()); + ui.label(login_textedit_info_text(self.i18n)); }); ui.vertical_centered_justified(|ui| { @@ -48,7 +56,7 @@ impl<'a> AccountLoginView<'a> { let button_width = 32.0; let text_edit_width = available_width - button_width; - let textedit_resp = ui.add_sized([text_edit_width, 40.0], login_textedit(self.manager)); + let textedit_resp = ui.add_sized([text_edit_width, 40.0], login_textedit(self.manager, self.i18n)); input_context(&textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear); if eye_button(ui, self.manager.password_visible()).clicked() { @@ -58,28 +66,28 @@ impl<'a> AccountLoginView<'a> { ui.with_layout(Layout::left_to_right(Align::TOP), |ui| { let help_text_style = NotedeckTextStyle::Small; ui.add(egui::Label::new( - RichText::new(tr!("Enter your public key (npub), nostr address (e.g. {address}), or private key (nsec). You must enter your private key to be able to post, reply, etc.", "Instructions for entering Nostr credentials", address="vrod@damus.io")) + RichText::new(tr!(self.i18n, "Enter your public key (npub), nostr address (e.g. {address}), or private key (nsec). You must enter your private key to be able to post, reply, etc.", "Instructions for entering Nostr credentials", address="vrod@damus.io")) .text_style(help_text_style.text_style()) .size(get_font_size(ui.ctx(), &help_text_style)).color(ui.visuals().weak_text_color()), ).wrap()) }); - self.manager.loading_and_error_ui(ui); + self.manager.loading_and_error_ui(ui, self.i18n); - if ui.add(login_button()).clicked() { + if ui.add(login_button(self.i18n)).clicked() { self.manager.apply_acquire(); } }); ui.horizontal(|ui| { ui.label( - RichText::new(tr!("New to Nostr?", "Label asking if the user is new to Nostr. Underneath this label is a button to create an account.")) + RichText::new(tr!(self.i18n,"New to Nostr?", "Label asking if the user is new to Nostr. Underneath this label is a button to create an account.")) .color(ui.style().visuals.noninteractive().fg_stroke.color) .text_style(NotedeckTextStyle::Body.text_style()), ); if ui - .add(Button::new(RichText::new(tr!("Create Account", "Button to create a new account"))).frame(false)) + .add(Button::new(RichText::new(tr!(self.i18n,"Create Account", "Button to create a new account"))).frame(false)) .clicked() { self.manager.should_create_new(); @@ -98,21 +106,21 @@ impl<'a> AccountLoginView<'a> { } } -fn login_title_text() -> RichText { - RichText::new(tr!("Login", "Login page title")) +fn login_title_text(i18n: &mut Localization) -> RichText { + RichText::new(tr!(i18n, "Login", "Login page title")) .text_style(NotedeckTextStyle::Heading2.text_style()) .strong() } -fn login_textedit_info_text() -> RichText { - RichText::new(tr!("Enter your key", "Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).")) +fn login_textedit_info_text(i18n: &mut Localization) -> RichText { + RichText::new(tr!(i18n, "Enter your key", "Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).")) .strong() .text_style(NotedeckTextStyle::Body.text_style()) } -fn login_button() -> Button<'static> { +fn login_button(i18n: &mut Localization) -> Button<'static> { Button::new( - RichText::new(tr!("Login now — let's do this!", "Login button text")) + RichText::new(tr!(i18n, "Login now — let's do this!", "Login button text")) .text_style(NotedeckTextStyle::Body.text_style()) .strong(), ) @@ -120,11 +128,15 @@ fn login_button() -> Button<'static> { .min_size(Vec2::new(0.0, 40.0)) } -fn login_textedit(manager: &mut AcquireKeyState) -> TextEdit { - let create_textedit: fn(&mut dyn TextBuffer) -> TextEdit = |text| { +fn login_textedit<'a>( + manager: &'a mut AcquireKeyState, + i18n: &'a mut Localization, +) -> TextEdit<'a> { + let create_textedit = |text| { egui::TextEdit::singleline(text) .hint_text( RichText::new(tr!( + i18n, "Your key here...", "Placeholder text for key input field" )) @@ -167,7 +179,7 @@ mod preview { impl App for AccountLoginPreview { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { - AccountLoginView::new(&mut self.manager, ctx.clipboard).ui(ui); + AccountLoginView::new(&mut self.manager, ctx.clipboard, ctx.i18n).ui(ui); None } diff --git a/crates/notedeck_columns/src/ui/accounts.rs b/crates/notedeck_columns/src/ui/accounts.rs @@ -3,16 +3,17 @@ use egui::{ }; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; -use notedeck::{tr, Accounts, Images}; +use notedeck::{tr, Accounts, Images, Localization}; use notedeck_ui::colors::PINK; +use notedeck_ui::profile::preview::SimpleProfilePreview; use notedeck_ui::app_images; -use notedeck_ui::profile::preview::SimpleProfilePreview; pub struct AccountsView<'a> { ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images, + i18n: &'a mut Localization, } #[derive(Clone, Debug)] @@ -29,24 +30,30 @@ enum ProfilePreviewAction { } impl<'a> AccountsView<'a> { - pub fn new(ndb: &'a Ndb, accounts: &'a Accounts, img_cache: &'a mut Images) -> Self { + pub fn new( + ndb: &'a Ndb, + accounts: &'a Accounts, + img_cache: &'a mut Images, + i18n: &'a mut Localization, + ) -> Self { AccountsView { ndb, accounts, img_cache, + i18n, } } pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<AccountsViewResponse>> { Frame::new().outer_margin(12.0).show(ui, |ui| { - if let Some(resp) = Self::top_section_buttons_widget(ui).inner { + if let Some(resp) = Self::top_section_buttons_widget(ui, self.i18n).inner { return Some(resp); } ui.add_space(8.0); scroll_area() .show(ui, |ui| { - Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache) + Self::show_accounts(ui, self.accounts, self.ndb, self.img_cache, self.i18n) }) .inner }) @@ -57,6 +64,7 @@ impl<'a> AccountsView<'a> { accounts: &Accounts, ndb: &Ndb, img_cache: &mut Images, + i18n: &mut Localization, ) -> Option<AccountsViewResponse> { let mut return_op: Option<AccountsViewResponse> = None; ui.allocate_ui_with_layout( @@ -79,8 +87,12 @@ impl<'a> AccountsView<'a> { 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| { - let preview = - SimpleProfilePreview::new(profile.as_ref(), img_cache, has_nsec); + let preview = SimpleProfilePreview::new( + profile.as_ref(), + img_cache, + i18n, + has_nsec, + ); show_profile_card(ui, preview, max_size, is_selected, resp) }) .inner @@ -104,12 +116,13 @@ impl<'a> AccountsView<'a> { fn top_section_buttons_widget( ui: &mut egui::Ui, + i18n: &mut Localization, ) -> InnerResponse<Option<AccountsViewResponse>> { ui.allocate_ui_with_layout( Vec2::new(ui.available_size_before_wrap().x, 32.0), Layout::left_to_right(egui::Align::Center), |ui| { - if ui.add(add_account_button()).clicked() { + if ui.add(add_account_button(i18n)).clicked() { Some(AccountsViewResponse::RouteToLogin) } else { None @@ -141,16 +154,14 @@ fn show_profile_card( .inner_margin(8.0) .show(ui, |ui| { ui.horizontal(|ui| { + let btn = sign_out_button(preview.i18n); ui.add(preview); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if card_resp.clicked() { op = Some(ProfilePreviewAction::SwitchTo); } - if ui - .add_sized(egui::Vec2::new(84.0, 32.0), sign_out_button()) - .clicked() - { + if ui.add_sized(egui::Vec2::new(84.0, 32.0), btn).clicked() { op = Some(ProfilePreviewAction::RemoveAccount) } }); @@ -168,19 +179,24 @@ fn scroll_area() -> ScrollArea { .auto_shrink([false; 2]) } -fn add_account_button() -> Button<'static> { +fn add_account_button(i18n: &mut Localization) -> Button<'static> { Button::image_and_text( app_images::add_account_image().fit_to_exact_size(Vec2::new(48.0, 48.0)), - RichText::new(tr!("Add account", "Button label to add a new account")) - .size(16.0) - // TODO: this color should not be hard coded. Find some way to add it to the visuals - .color(PINK), + RichText::new(tr!( + i18n, + "Add account", + "Button label to add a new account" + )) + .size(16.0) + // TODO: this color should not be hard coded. Find some way to add it to the visuals + .color(PINK), ) .frame(false) } -fn sign_out_button() -> egui::Button<'static> { +fn sign_out_button(i18n: &mut Localization) -> egui::Button<'static> { egui::Button::new(RichText::new(tr!( + i18n, "Sign out", "Button label to sign out of account" ))) diff --git a/crates/notedeck_columns/src/ui/add_column.rs b/crates/notedeck_columns/src/ui/add_column.rs @@ -17,7 +17,7 @@ use crate::{ Damus, }; -use notedeck::{tr, AppContext, Images, NotedeckTextStyle, UserAccount}; +use notedeck::{tr, AppContext, Images, Localization, NotedeckTextStyle, UserAccount}; use notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; @@ -167,6 +167,7 @@ pub struct AddColumnView<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: &'a UserAccount, + i18n: &'a mut Localization, } impl<'a> AddColumnView<'a> { @@ -175,12 +176,14 @@ impl<'a> AddColumnView<'a> { ndb: &'a Ndb, img_cache: &'a mut Images, cur_account: &'a UserAccount, + i18n: &'a mut Localization, ) -> Self { Self { key_state_map, ndb, img_cache, cur_account, + i18n, } } @@ -229,8 +232,9 @@ impl<'a> AddColumnView<'a> { deck_author: Pubkey, ) -> Option<AddColumnResponse> { let algo_option = ColumnOptionData { - title: tr!("Contact List", "Title for contact list column"), + title: tr!(self.i18n, "Contact List", "Title for contact list column"), description: tr!( + self.i18n, "Source the last note for each user in your contact list", "Description for contact list column" ), @@ -248,8 +252,13 @@ impl<'a> AddColumnView<'a> { fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { let algo_option = ColumnOptionData { - title: tr!("Last Note per User", "Title for last note per user column"), + title: tr!( + self.i18n, + "Last Note per User", + "Title for last note per user column" + ), description: tr!( + self.i18n, "Show the last note for each user from a list", "Description for last note per user column" ), @@ -298,6 +307,7 @@ impl<'a> AddColumnView<'a> { egui::TextEdit::singleline(text) .hint_text( RichText::new(tr!( + self.i18n, "Enter the user's key (npub, hex, nip05) here...", "Hint text to prompt entering the user's public key." )) @@ -312,9 +322,11 @@ impl<'a> AddColumnView<'a> { ui.add(text_edit); key_state.handle_input_change_after_acquire(); - key_state.loading_and_error_ui(ui); + key_state.loading_and_error_ui(ui, self.i18n); - if key_state.get_login_keypair().is_none() && ui.add(find_user_button()).clicked() { + if key_state.get_login_keypair().is_none() + && ui.add(find_user_button(self.i18n)).clicked() + { key_state.apply_acquire(); } @@ -337,7 +349,7 @@ impl<'a> AddColumnView<'a> { } } - ui.add(add_column_button()) + ui.add(add_column_button(self.i18n)) .clicked() .then(|| to_option(keypair.pubkey).take_as_response(self.cur_account)) } else { @@ -452,11 +464,12 @@ impl<'a> AddColumnView<'a> { helper.take_animation_response() } - fn get_base_options(&self, ui: &mut Ui) -> Vec<ColumnOptionData> { + fn get_base_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { let mut vec = Vec::new(); vec.push(ColumnOptionData { - title: tr!("Home", "Title for Home column"), + title: tr!(self.i18n, "Home", "Title for Home column"), description: tr!( + self.i18n, "See notes from your contacts", "Description for Home column" ), @@ -468,8 +481,9 @@ impl<'a> AddColumnView<'a> { }), }); vec.push(ColumnOptionData { - title: tr!("Notifications", "Title for notifications column"), + title: tr!(self.i18n, "Notifications", "Title for notifications column"), description: tr!( + self.i18n, "Stay up to date with notifications and mentions", "Description for notifications column" ), @@ -477,8 +491,9 @@ impl<'a> AddColumnView<'a> { option: AddColumnOption::UndecidedNotification, }); vec.push(ColumnOptionData { - title: tr!("Universe", "Title for universe column"), + title: tr!(self.i18n, "Universe", "Title for universe column"), description: tr!( + self.i18n, "See the whole nostr universe", "Description for universe column" ), @@ -486,8 +501,9 @@ impl<'a> AddColumnView<'a> { option: AddColumnOption::Universe, }); vec.push(ColumnOptionData { - title: tr!("Hashtags", "Title for hashtags column"), + title: tr!(self.i18n, "Hashtags", "Title for hashtags column"), description: tr!( + self.i18n, "Stay up to date with a certain hashtag", "Description for hashtags column" ), @@ -495,8 +511,9 @@ impl<'a> AddColumnView<'a> { option: AddColumnOption::UndecidedHashtag, }); vec.push(ColumnOptionData { - title: tr!("Individual", "Title for individual user column"), + title: tr!(self.i18n, "Individual", "Title for individual user column"), description: tr!( + self.i18n, "Stay up to date with someone's notes & replies", "Description for individual user column" ), @@ -504,8 +521,9 @@ impl<'a> AddColumnView<'a> { option: AddColumnOption::UndecidedIndividual, }); vec.push(ColumnOptionData { - title: tr!("Algo", "Title for algorithmic feeds column"), + title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"), description: tr!( + self.i18n, "Algorithmic feeds to aid in note discovery", "Description for algorithmic feeds column" ), @@ -516,7 +534,7 @@ impl<'a> AddColumnView<'a> { vec } - fn get_notifications_options(&self, ui: &mut Ui) -> Vec<ColumnOptionData> { + fn get_notifications_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> { let mut vec = Vec::new(); let source = if self.cur_account.key.secret_key.is_some() { @@ -526,8 +544,13 @@ impl<'a> AddColumnView<'a> { }; vec.push(ColumnOptionData { - title: tr!("Your Notifications", "Title for your notifications column"), + title: tr!( + self.i18n, + "Your Notifications", + "Title for your notifications column" + ), description: tr!( + self.i18n, "Stay up to date with your notifications and mentions", "Description for your notifications column" ), @@ -537,10 +560,12 @@ impl<'a> AddColumnView<'a> { vec.push(ColumnOptionData { title: tr!( + self.i18n, "Someone else's Notifications", "Title for someone else's notifications column" ), description: tr!( + self.i18n, "Stay up to date with someone else's notifications and mentions", "Description for someone else's notifications column" ), @@ -551,7 +576,7 @@ impl<'a> AddColumnView<'a> { vec } - fn get_individual_options(&self) -> Vec<ColumnOptionData> { + fn get_individual_options(&mut self) -> Vec<ColumnOptionData> { let mut vec = Vec::new(); let source = if self.cur_account.key.secret_key.is_some() { @@ -561,8 +586,9 @@ impl<'a> AddColumnView<'a> { }; vec.push(ColumnOptionData { - title: tr!("Your Notes", "Title for your notes column"), + title: tr!(self.i18n, "Your Notes", "Title for your notes column"), description: tr!( + self.i18n, "Keep track of your notes & replies", "Description for your notes column" ), @@ -572,10 +598,12 @@ impl<'a> AddColumnView<'a> { vec.push(ColumnOptionData { title: tr!( + self.i18n, "Someone else's Notes", "Title for someone else's notes column" ), description: tr!( + self.i18n, "Stay up to date with someone else's notes & replies", "Description for someone else's notes column" ), @@ -587,14 +615,14 @@ impl<'a> AddColumnView<'a> { } } -fn find_user_button() -> impl Widget { - let label = tr!("Find User", "Label for find user button"); +fn find_user_button(i18n: &mut Localization) -> impl Widget { + let label = tr!(i18n, "Find User", "Label for find user button"); let color = notedeck_ui::colors::PINK; move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) } -fn add_column_button() -> impl Widget { - let label = tr!("Add", "Label for add column button"); +fn add_column_button(i18n: &mut Localization) -> impl Widget { + let label = tr!(i18n, "Add", "Label for add column button"); let color = notedeck_ui::colors::PINK; move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui) } @@ -639,6 +667,7 @@ pub fn render_add_column_routes( ctx.ndb, ctx.img_cache, ctx.accounts.get_selected_account(), + ctx.i18n, ); let resp = match route { AddColumnRoute::Base => add_column_view.ui(ui), @@ -649,7 +678,7 @@ pub fn render_add_column_routes( }, AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), - AddColumnRoute::Hashtag => hashtag_ui(ui, &mut app.view_state.id_string_map), + AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map), AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui), AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), }; @@ -677,7 +706,7 @@ pub fn render_add_column_routes( ctx.accounts, ); - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to_replaced(Route::timeline(timeline.kind.clone())); @@ -689,7 +718,7 @@ pub fn render_add_column_routes( // If we are undecided, we simply route to the LastPerPubkey // algo route selection AlgoOption::LastPerPubkey(Decision::Undecided) => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(Route::AddColumn(AddColumnRoute::Algo( @@ -717,7 +746,7 @@ pub fn render_add_column_routes( ctx.accounts, ); - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to_replaced(Route::timeline(timeline.kind.clone())); @@ -735,13 +764,13 @@ pub fn render_add_column_routes( }, AddColumnResponse::UndecidedNotification => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); } AddColumnResponse::ExternalNotification => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( @@ -749,13 +778,13 @@ pub fn render_add_column_routes( )); } AddColumnResponse::Hashtag => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); } AddColumnResponse::UndecidedIndividual => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( @@ -763,7 +792,7 @@ pub fn render_add_column_routes( )); } AddColumnResponse::ExternalIndividual => { - app.columns_mut(ctx.accounts) + app.columns_mut(ctx.i18n, ctx.accounts) .column_mut(col) .router_mut() .route_to(crate::route::Route::AddColumn( @@ -776,6 +805,7 @@ pub fn render_add_column_routes( pub fn hashtag_ui( ui: &mut Ui, + i18n: &mut Localization, id_string_map: &mut HashMap<Id, String>, ) -> Option<AddColumnResponse> { padding(16.0, ui, |ui| { @@ -785,6 +815,7 @@ pub fn hashtag_ui( let text_edit = egui::TextEdit::singleline(text_buffer) .hint_text( RichText::new(tr!( + i18n, "Enter the desired hashtags here (for multiple space-separated)", "Placeholder for hashtag input field" )) @@ -801,7 +832,7 @@ pub fn hashtag_ui( let mut handle_user_input = false; if ui.input(|i| i.key_released(egui::Key::Enter)) || ui - .add_sized(egui::vec2(50.0, 40.0), add_column_button()) + .add_sized(egui::vec2(50.0, 40.0), add_column_button(i18n)) .clicked() { handle_user_input = true; diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs @@ -13,7 +13,7 @@ use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder}; use enostr::Pubkey; use nostrdb::{Ndb, Transaction}; use notedeck::tr; -use notedeck::{Images, NotedeckTextStyle}; +use notedeck::{Images, Localization, NotedeckTextStyle}; use notedeck_ui::app_images; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, @@ -27,6 +27,7 @@ pub struct NavTitle<'a> { routes: &'a [Route], col_id: usize, options: u32, + i18n: &'a mut Localization, } impl<'a> NavTitle<'a> { @@ -40,6 +41,7 @@ impl<'a> NavTitle<'a> { columns: &'a Columns, routes: &'a [Route], col_id: usize, + i18n: &'a mut Localization, ) -> Self { let options = Self::SHOW_MOVE | Self::SHOW_DELETE; NavTitle { @@ -49,6 +51,7 @@ impl<'a> NavTitle<'a> { routes, col_id, options, + i18n, } } @@ -129,7 +132,7 @@ impl<'a> NavTitle<'a> { // NOTE(jb55): include graphic in back label as well because why // not it looks cool let pfp_resp = self.title_pfp(ui, prev, 32.0); - let column_title = prev.title(); + let column_title = prev.title(self.i18n); let back_resp = match &column_title { ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)), @@ -182,7 +185,7 @@ impl<'a> NavTitle<'a> { animation_resp } - fn delete_button_section(&self, ui: &mut egui::Ui) -> bool { + fn delete_button_section(&mut self, ui: &mut egui::Ui) -> bool { let id = ui.id().with("title"); let delete_button_resp = self.delete_column_button(ui, 32.0); @@ -193,14 +196,18 @@ impl<'a> NavTitle<'a> { if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) { let mut confirm_pressed = false; delete_button_resp.show_tooltip_ui(|ui| { - let confirm_resp = ui.button(tr!("Confirm", "Button label to confirm an action")); + let confirm_resp = ui.button(tr!( + self.i18n, + "Confirm", + "Button label to confirm an action" + )); if confirm_resp.clicked() { confirm_pressed = true; } if confirm_resp.clicked() || ui - .button(tr!("Cancel", "Button label to cancel an action")) + .button(tr!(self.i18n, "Cancel", "Button label to cancel an action")) .clicked() { ui.data_mut(|d| d.insert_temp(id, false)); @@ -211,8 +218,11 @@ impl<'a> NavTitle<'a> { } confirm_pressed } else { - delete_button_resp - .on_hover_text(tr!("Delete this column", "Tooltip for deleting a column")); + delete_button_resp.on_hover_text(tr!( + self.i18n, + "Delete this column", + "Tooltip for deleting a column" + )); false } } @@ -227,6 +237,7 @@ impl<'a> NavTitle<'a> { // showing the hover text while showing the move tooltip causes some weird visuals if ui.data(|d| d.get_temp::<bool>(cur_id).is_none()) { move_resp = move_resp.on_hover_text(tr!( + self.i18n, "Moves this column to another position", "Tooltip for moving a column" )); @@ -522,8 +533,8 @@ impl<'a> NavTitle<'a> { .selectable(false) } - fn title_label(&self, ui: &mut egui::Ui, top: &Route) { - let column_title = top.title(); + fn title_label(&mut self, ui: &mut egui::Ui, top: &Route) { + let column_title = top.title(self.i18n); match &column_title { ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)), diff --git a/crates/notedeck_columns/src/ui/configure_deck.rs b/crates/notedeck_columns/src/ui/configure_deck.rs @@ -1,6 +1,6 @@ use crate::{app_style::deck_icon_font_sized, deck_state::DeckState}; use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; -use notedeck::tr; +use notedeck::{tr, Localization}; use notedeck::{NamedFontFamily, NotedeckTextStyle}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, @@ -11,6 +11,7 @@ use notedeck_ui::{ pub struct ConfigureDeckView<'a> { state: &'a mut DeckState, create_button_text: String, + pub i18n: &'a mut Localization, } pub struct ConfigureDeckResponse { @@ -19,10 +20,11 @@ pub struct ConfigureDeckResponse { } impl<'a> ConfigureDeckView<'a> { - pub fn new(state: &'a mut DeckState) -> Self { + pub fn new(state: &'a mut DeckState, i18n: &'a mut Localization) -> Self { Self { state, - create_button_text: tr!("Create Deck", "Button label to create a new deck"), + create_button_text: tr!(i18n, "Create Deck", "Button label to create a new deck"), + i18n, } } @@ -38,14 +40,19 @@ impl<'a> ConfigureDeckView<'a> { ); padding(16.0, ui, |ui| { ui.add(Label::new( - RichText::new(tr!("Deck name", "Label for deck name input field")) - .font(title_font.clone()), + RichText::new(tr!( + self.i18n, + "Deck name", + "Label for deck name input field" + )) + .font(title_font.clone()), )); ui.add_space(8.0); ui.text_edit_singleline(&mut self.state.deck_name); ui.add_space(8.0); ui.add(Label::new( RichText::new(tr!( + self.i18n, "We recommend short names", "Hint for deck name input field" )) @@ -58,7 +65,8 @@ impl<'a> ConfigureDeckView<'a> { ui.add_space(32.0); ui.add(Label::new( - RichText::new(tr!("Icon", "Label for deck icon selection")).font(title_font), + RichText::new(tr!(self.i18n, "Icon", "Label for deck icon selection")) + .font(title_font), )); if ui @@ -97,7 +105,12 @@ impl<'a> ConfigureDeckView<'a> { self.state.warn_no_title = false; } - show_warnings(ui, self.state.warn_no_icon, self.state.warn_no_title); + show_warnings( + ui, + self.i18n, + self.state.warn_no_icon, + self.state.warn_no_title, + ); let mut resp = None; if ui @@ -125,19 +138,22 @@ impl<'a> ConfigureDeckView<'a> { } } -fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) { +fn show_warnings(ui: &mut Ui, i18n: &mut Localization, warn_no_icon: bool, warn_no_title: bool) { let warning = if warn_no_title && warn_no_icon { tr!( + i18n, "Please create a name for the deck and select an icon.", "Error message for missing deck name and icon" ) } else if warn_no_title { tr!( + i18n, "Please create a name for the deck.", "Error message for missing deck name" ) } else if warn_no_icon { tr!( + i18n, "Please select an icon.", "Error message for missing deck icon" ) @@ -320,12 +336,8 @@ mod preview { } impl App for ConfigureDeckPreview { - fn update( - &mut self, - _app_ctx: &mut AppContext<'_>, - ui: &mut egui::Ui, - ) -> Option<AppAction> { - ConfigureDeckView::new(&mut self.state).ui(ui); + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { + ConfigureDeckView::new(&mut self.state, ctx.i18n).ui(ui); None } diff --git a/crates/notedeck_columns/src/ui/edit_deck.rs b/crates/notedeck_columns/src/ui/edit_deck.rs @@ -3,7 +3,7 @@ use egui::Widget; use crate::deck_state::DeckState; use super::configure_deck::{ConfigureDeckResponse, ConfigureDeckView}; -use notedeck::tr; +use notedeck::{tr, Localization}; use notedeck_ui::padding; pub struct EditDeckView<'a> { @@ -16,9 +16,9 @@ pub enum EditDeckResponse { } impl<'a> EditDeckView<'a> { - pub fn new(state: &'a mut DeckState) -> Self { - let config_view = ConfigureDeckView::new(state) - .with_create_text(tr!("Edit Deck", "Button label to edit a deck")); + pub fn new(state: &'a mut DeckState, i18n: &'a mut Localization) -> Self { + let txt = tr!(i18n, "Edit Deck", "Button label to edit a deck"); + let config_view = ConfigureDeckView::new(state, i18n).with_create_text(txt); Self { config_view } } @@ -26,7 +26,7 @@ impl<'a> EditDeckView<'a> { let mut edit_deck_resp = None; padding(egui::Margin::symmetric(16, 4), ui, |ui| { - if ui.add(delete_button()).clicked() { + if ui.add(delete_button(self.config_view.i18n)).clicked() { edit_deck_resp = Some(EditDeckResponse::Delete); } }); @@ -39,12 +39,12 @@ impl<'a> EditDeckView<'a> { } } -fn delete_button() -> impl Widget { +fn delete_button<'a>(i18n: &'a mut Localization) -> impl Widget + 'a { |ui: &mut egui::Ui| { let size = egui::vec2(108.0, 40.0); ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { ui.add( - egui::Button::new(tr!("Delete Deck", "Button label to delete a deck")) + egui::Button::new(tr!(i18n, "Delete Deck", "Button label to delete a deck")) .fill(ui.visuals().error_fg_color) .min_size(size), ) @@ -75,12 +75,8 @@ mod preview { } impl App for EditDeckPreview { - fn update( - &mut self, - _app_ctx: &mut AppContext<'_>, - ui: &mut egui::Ui, - ) -> Option<AppAction> { - EditDeckView::new(&mut self.state).ui(ui); + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { + EditDeckView::new(&mut self.state, ctx.i18n).ui(ui); None } } diff --git a/crates/notedeck_columns/src/ui/note/custom_zap.rs b/crates/notedeck_columns/src/ui/note/custom_zap.rs @@ -1,5 +1,3 @@ -use std::fmt::Display; - use egui::{ emath::GuiRounding, pos2, vec2, Color32, CornerRadius, FontId, Frame, Label, Layout, Slider, Stroke, @@ -7,7 +5,8 @@ use egui::{ use enostr::Pubkey; use nostrdb::{Ndb, ProfileRecord, Transaction}; use notedeck::{ - fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, NotedeckTextStyle, + fonts::get_font_size, get_profile_url, name::get_display_name, tr, Images, Localization, + NotedeckTextStyle, }; use notedeck_ui::{ app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable, @@ -20,11 +19,13 @@ pub struct CustomZapView<'a> { txn: &'a Transaction, target_pubkey: &'a Pubkey, default_msats: u64, + i18n: &'a mut Localization, } #[allow(clippy::new_without_default)] impl<'a> CustomZapView<'a> { pub fn new( + i18n: &'a mut Localization, images: &'a mut Images, ndb: &'a Ndb, txn: &'a Transaction, @@ -37,6 +38,7 @@ impl<'a> CustomZapView<'a> { ndb, txn, default_msats, + i18n, } } @@ -48,7 +50,7 @@ impl<'a> CustomZapView<'a> { } fn ui_internal(&mut self, ui: &mut egui::Ui) -> Option<u64> { - show_title(ui); + show_title(ui, self.i18n); ui.add_space(16.0); @@ -82,7 +84,7 @@ impl<'a> CustomZapView<'a> { } else { (self.default_msats / 1000).to_string() }; - show_amount(ui, id, &mut cur_amount, slider_width); + show_amount(ui, self.i18n, id, &mut cur_amount, slider_width); let mut maybe_sats = cur_amount.parse::<u64>().ok(); let prev_slider_sats = maybe_sats.unwrap_or(default_sats).clamp(1, 100000); @@ -102,7 +104,7 @@ impl<'a> CustomZapView<'a> { maybe_sats = Some(slider_sats); } - if let Some(selection) = show_selection_buttons(ui, maybe_sats) { + if let Some(selection) = show_selection_buttons(ui, maybe_sats, self.i18n) { cur_amount = selection.to_string(); maybe_sats = Some(selection); } @@ -110,7 +112,7 @@ impl<'a> CustomZapView<'a> { ui.data_mut(|d| d.insert_temp(id, cur_amount)); let resp = ui.add(styled_button_toggleable( - &tr!("Send", "Button label to send a zap"), + &tr!(self.i18n, "Send", "Button label to send a zap"), colors::PINK, is_valid_zap(maybe_sats), )); @@ -129,7 +131,7 @@ fn is_valid_zap(amount: Option<u64>) -> bool { amount.is_some_and(|sats| sats > 0) } -fn show_title(ui: &mut egui::Ui) { +fn show_title(ui: &mut egui::Ui, i18n: &mut Localization) { let max_size = 32.0; ui.allocate_ui_with_layout( vec2(ui.available_width(), max_size), @@ -158,7 +160,7 @@ fn show_title(ui: &mut egui::Ui) { ui.add_space(8.0); ui.add(egui::Label::new( - egui::RichText::new(tr!("Zap", "Heading for zap (tip) action")) + egui::RichText::new(tr!(i18n, "Zap", "Heading for zap (tip) action")) .text_style(NotedeckTextStyle::Heading2.text_style()), )); }, @@ -177,7 +179,13 @@ fn show_profile(ui: &mut egui::Ui, images: &mut Images, profile: Option<&Profile ); } -fn show_amount(ui: &mut egui::Ui, id: egui::Id, user_input: &mut String, width: f32) { +fn show_amount( + ui: &mut egui::Ui, + i18n: &mut Localization, + id: egui::Id, + user_input: &mut String, + width: f32, +) { let user_input_font = NotedeckTextStyle::Heading.get_bolded_font(ui.ctx()); let user_input_id = id.with("sats_amount"); @@ -192,6 +200,7 @@ fn show_amount(ui: &mut egui::Ui, id: egui::Id, user_input: &mut String, width: let sats_galley = painter.layout_no_wrap( tr!( + i18n, "SATS", "Label for satoshis (Bitcoin unit) for custom zap amount input field" ), @@ -219,7 +228,7 @@ fn show_amount(ui: &mut egui::Ui, id: egui::Id, user_input: &mut String, width: .font(user_input_font); let amount_resp = ui.add(Label::new( - egui::RichText::new(tr!("Amount", "Label for zap amount input field")) + egui::RichText::new(tr!(i18n, "Amount", "Label for zap amount input field")) .text_style(NotedeckTextStyle::Heading3.text_style()) .color(ui.visuals().noninteractive().text_color()), )); @@ -300,7 +309,11 @@ const SELECTION_BUTTONS: [ZapSelectionButton; 8] = [ ZapSelectionButton::Eighth, ]; -fn show_selection_buttons(ui: &mut egui::Ui, sats_selection: Option<u64>) -> Option<u64> { +fn show_selection_buttons( + ui: &mut egui::Ui, + sats_selection: Option<u64>, + i18n: &mut Localization, +) -> Option<u64> { let mut our_selection = None; ui.allocate_ui_with_layout( vec2(224.0, 116.0), @@ -309,7 +322,8 @@ fn show_selection_buttons(ui: &mut egui::Ui, sats_selection: Option<u64>) -> Opt ui.spacing_mut().item_spacing = vec2(8.0, 8.0); for button in SELECTION_BUTTONS { - our_selection = our_selection.or(show_selection_button(ui, sats_selection, button)); + our_selection = + our_selection.or(show_selection_button(ui, sats_selection, button, i18n)); } }, ); @@ -321,6 +335,7 @@ fn show_selection_button( ui: &mut egui::Ui, sats_selection: Option<u64>, button: ZapSelectionButton, + i18n: &mut Localization, ) -> Option<u64> { let (rect, _) = ui.allocate_exact_size(vec2(50.0, 50.0), egui::Sense::click()); let helper = AnimationHelper::new_from_rect(ui, ("zap_selection_button", &button), rect); @@ -353,7 +368,11 @@ fn show_selection_button( NotedeckTextStyle::Body.font_family(), ); - let galley = painter.layout_no_wrap(button.to_string(), fontid, ui.visuals().text_color()); + let galley = painter.layout_no_wrap( + button.to_desc_string(i18n), + fontid, + ui.visuals().text_color(), + ); let text_rect = { let mut galley_rect = galley.rect; galley_rect.set_center(rect.center()); @@ -394,19 +413,17 @@ impl ZapSelectionButton { ZapSelectionButton::Eighth => 100_000, } } -} -impl Display for ZapSelectionButton { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + pub fn to_desc_string(&self, i18n: &mut Localization) -> String { match self { - ZapSelectionButton::First => write!(f, "69"), - ZapSelectionButton::Second => write!(f, "100"), - ZapSelectionButton::Third => write!(f, "420"), - ZapSelectionButton::Fourth => write!(f, "{}", tr!("5K", "Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.")), - ZapSelectionButton::Fifth => write!(f, "{}", tr!("10K", "Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.")), - ZapSelectionButton::Sixth => write!(f, "{}", tr!("20K", "Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.")), - ZapSelectionButton::Seventh => write!(f, "{}", tr!("50K", "Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.")), - ZapSelectionButton::Eighth => write!(f, "{}", tr!("100K", "Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.")), + ZapSelectionButton::First => "69".to_string(), + ZapSelectionButton::Second => "100".to_string(), + ZapSelectionButton::Third => "420".to_string(), + ZapSelectionButton::Fourth => tr!(i18n, "5K", "Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount."), + ZapSelectionButton::Fifth => tr!(i18n, "10K", "Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount."), + ZapSelectionButton::Sixth => tr!(i18n, "20K", "Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount."), + ZapSelectionButton::Seventh => tr!(i18n, "50K", "Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount."), + ZapSelectionButton::Eighth => tr!(i18n, "100K", "Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount."), } } } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -25,7 +25,9 @@ use notedeck_ui::{ NoteOptions, ProfilePic, }; -use notedeck::{name::get_display_name, supported_mime_hosted_at_url, tr, NoteAction, NoteContext}; +use notedeck::{ + name::get_display_name, supported_mime_hosted_at_url, tr, Localization, NoteAction, NoteContext, +}; use tracing::error; pub struct PostView<'a, 'd> { @@ -182,6 +184,7 @@ impl<'a, 'd> PostView<'a, 'd> { let textedit = TextEdit::multiline(&mut self.draft.buffer) .hint_text( egui::RichText::new(tr!( + self.note_context.i18n, "Write a banger note here...", "Placeholder for note input field" )) @@ -411,7 +414,10 @@ impl<'a, 'd> PostView<'a, 'd> { ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| { let post_button_clicked = ui - .add_sized([91.0, 32.0], post_button(!self.draft.buffer.is_empty())) + .add_sized( + [91.0, 32.0], + post_button(self.note_context.i18n, !self.draft.buffer.is_empty()), + ) .clicked(); let shortcut_pressed = ui.input(|i| { @@ -609,9 +615,9 @@ fn render_post_view_media( } } -fn post_button(interactive: bool) -> impl egui::Widget { +fn post_button<'a>(i18n: &'a mut Localization, interactive: bool) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| { - let button = egui::Button::new(tr!("Post now", "Button label to post a note")); + let button = egui::Button::new(tr!(i18n, "Post now", "Button label to post a note")); if interactive { ui.add(button) } else { @@ -804,6 +810,7 @@ mod preview { unknown_ids: app.unknown_ids, current_account_has_wallet: false, clipboard: app.clipboard, + i18n: app.i18n, }; PostView::new( diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -2,17 +2,26 @@ use core::f32; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; use enostr::ProfileState; -use notedeck::{profile::unwrap_profile_url, tr, Images, NotedeckTextStyle}; +use notedeck::{profile::unwrap_profile_url, tr, Images, Localization, NotedeckTextStyle}; use notedeck_ui::{profile::banner, ProfilePic}; pub struct EditProfileView<'a> { state: &'a mut ProfileState, img_cache: &'a mut Images, + i18n: &'a mut Localization, } impl<'a> EditProfileView<'a> { - pub fn new(state: &'a mut ProfileState, img_cache: &'a mut Images) -> Self { - Self { state, img_cache } + pub fn new( + i18n: &'a mut Localization, + state: &'a mut ProfileState, + img_cache: &'a mut Images, + ) -> Self { + Self { + i18n, + state, + img_cache, + } } // return true to save @@ -34,8 +43,12 @@ impl<'a> EditProfileView<'a> { if ui .add( button( - tr!("Save changes", "Button label to save profile changes") - .as_str(), + tr!( + self.i18n, + "Save changes", + "Button label to save profile changes" + ) + .as_str(), 119.0, ) .fill(notedeck_ui::colors::PINK), @@ -70,42 +83,52 @@ impl<'a> EditProfileView<'a> { in_frame(ui, |ui| { ui.add(label( - tr!("Display name", "Profile display name field label").as_str(), + tr!( + self.i18n, + "Display name", + "Profile display name field label" + ) + .as_str(), )); ui.add(singleline_textedit(self.state.str_mut("display_name"))); }); in_frame(ui, |ui| { ui.add(label( - tr!("Username", "Profile username field label").as_str(), + tr!(self.i18n, "Username", "Profile username field label").as_str(), )); ui.add(singleline_textedit(self.state.str_mut("name"))); }); in_frame(ui, |ui| { ui.add(label( - tr!("Profile picture", "Profile picture URL field label").as_str(), + tr!( + self.i18n, + "Profile picture", + "Profile picture URL field label" + ) + .as_str(), )); ui.add(multiline_textedit(self.state.str_mut("picture"))); }); in_frame(ui, |ui| { ui.add(label( - tr!("Banner", "Profile banner URL field label").as_str(), + tr!(self.i18n, "Banner", "Profile banner URL field label").as_str(), )); ui.add(multiline_textedit(self.state.str_mut("banner"))); }); in_frame(ui, |ui| { ui.add(label( - tr!("About", "Profile about/bio field label").as_str(), + tr!(self.i18n, "About", "Profile about/bio field label").as_str(), )); ui.add(multiline_textedit(self.state.str_mut("about"))); }); in_frame(ui, |ui| { ui.add(label( - tr!("Website", "Profile website field label").as_str(), + tr!(self.i18n, "Website", "Profile website field label").as_str(), )); ui.add(singleline_textedit(self.state.str_mut("website"))); }); @@ -113,6 +136,7 @@ impl<'a> EditProfileView<'a> { in_frame(ui, |ui| { ui.add(label( tr!( + self.i18n, "Lightning network address (lud16)", "Bitcoin Lightning network address field label" ) @@ -124,6 +148,7 @@ impl<'a> EditProfileView<'a> { in_frame(ui, |ui| { ui.add(label( tr!( + self.i18n, "Nostr address (NIP-05 identity)", "NIP-05 identity field label" ) @@ -153,12 +178,14 @@ impl<'a> EditProfileView<'a> { ui.visuals().noninteractive().fg_stroke.color, RichText::new(if use_domain { tr!( + self.i18n, "\"{domain}\" will be used for identification", "Domain identification message", domain = suffix ) } else { tr!( + self.i18n, "\"{username}\" at \"{domain}\" will be used for identification", "Username and domain identification message", username = prefix, diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs @@ -4,7 +4,7 @@ pub use edit::EditProfileView; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use enostr::Pubkey; use nostrdb::{ProfileRecord, Transaction}; -use notedeck::tr; +use notedeck::{tr, Localization}; use notedeck_ui::profile::follow_button; use tracing::error; @@ -91,8 +91,12 @@ impl<'a, 'd> ProfileView<'a, 'd> { ) .get_ptr(); - profile_timeline.selected_view = - tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); + profile_timeline.selected_view = tabs_ui( + ui, + self.note_context.i18n, + profile_timeline.selected_view, + &profile_timeline.views, + ); let reversed = false; // poll for new notes and insert them into our existing notes @@ -184,7 +188,10 @@ impl<'a, 'd> ProfileView<'a, 'd> { match profile_type { ProfileType::MyProfile => { - if ui.add(edit_profile_button()).clicked() { + if ui + .add(edit_profile_button(self.note_context.i18n)) + .clicked() + { action = Some(ProfileViewAction::EditProfile); } } @@ -334,7 +341,7 @@ fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ { } } -fn edit_profile_button() -> impl egui::Widget + 'static { +fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a { |ui: &mut egui::Ui| -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); let painter = ui.painter_at(rect); @@ -363,7 +370,7 @@ fn edit_profile_button() -> impl egui::Widget + 'static { let edit_icon_size = vec2(16.0, 16.0); let galley = painter.layout( - tr!("Edit Profile", "Button label to edit user profile"), + tr!(i18n, "Edit Profile", "Button label to edit user profile"), NotedeckTextStyle::Button.get_font_id(ui.ctx()), ui.visuals().text_color(), rect.width(), diff --git a/crates/notedeck_columns/src/ui/relay.rs b/crates/notedeck_columns/src/ui/relay.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use crate::ui::{Preview, PreviewConfig}; use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2}; use enostr::{RelayPool, RelayStatus}; -use notedeck::{tr, NotedeckTextStyle, RelayAction}; +use notedeck::{tr, Localization, NotedeckTextStyle, RelayAction}; use notedeck_ui::app_images; use notedeck_ui::{colors::PINK, padding}; use tracing::debug; @@ -13,6 +13,7 @@ use super::widgets::styled_button; pub struct RelayView<'a> { pool: &'a RelayPool, id_string_map: &'a mut HashMap<Id, String>, + i18n: &'a mut Localization, } impl RelayView<'_> { @@ -26,7 +27,7 @@ impl RelayView<'_> { ui.horizontal(|ui| { ui.with_layout(Layout::left_to_right(Align::Center), |ui| { ui.label( - RichText::new(tr!("Relays", "Label for relay list section")) + RichText::new(tr!(self.i18n, "Relays", "Label for relay list section")) .text_style(NotedeckTextStyle::Heading2.text_style()), ); }); @@ -53,10 +54,15 @@ impl RelayView<'_> { } impl<'a> RelayView<'a> { - pub fn new(pool: &'a RelayPool, id_string_map: &'a mut HashMap<Id, String>) -> Self { + pub fn new( + pool: &'a RelayPool, + id_string_map: &'a mut HashMap<Id, String>, + i18n: &'a mut Localization, + ) -> Self { RelayView { pool, id_string_map, + i18n, } } @@ -65,7 +71,7 @@ impl<'a> RelayView<'a> { } /// Show the current relays and return a relay the user selected to delete - fn show_relays(&'a self, ui: &mut Ui) -> Option<String> { + fn show_relays(&mut self, ui: &mut Ui) -> Option<String> { let mut relay_to_remove = None; for (index, relay_info) in get_relay_infos(self.pool).iter().enumerate() { ui.add_space(8.0); @@ -107,7 +113,7 @@ impl<'a> RelayView<'a> { relay_to_remove = Some(relay_info.relay_url.to_string()); }; - show_connection_status(ui, relay_info.status); + show_connection_status(ui, self.i18n, relay_info.status); }); }); }); @@ -123,7 +129,7 @@ impl<'a> RelayView<'a> { match self.id_string_map.get(&id) { None => { ui.with_layout(Layout::top_down(Align::Min), |ui| { - let relay_button = add_relay_button(); + let relay_button = add_relay_button(self.i18n); if ui.add(relay_button).clicked() { debug!("add relay clicked"); self.id_string_map @@ -151,6 +157,7 @@ impl<'a> RelayView<'a> { let text_edit = egui::TextEdit::singleline(text_buffer) .hint_text( RichText::new(tr!( + self.i18n, "Enter the relay here", "Placeholder for relay input field" )) @@ -163,7 +170,10 @@ impl<'a> RelayView<'a> { ui.add(text_edit); ui.add_space(8.0); if ui - .add_sized(egui::vec2(50.0, 40.0), add_relay_button2(is_enabled)) + .add_sized( + egui::vec2(50.0, 40.0), + add_relay_button2(self.i18n, is_enabled), + ) .clicked() { self.id_string_map.remove(&id) // remove and return the value @@ -175,10 +185,10 @@ impl<'a> RelayView<'a> { } } -fn add_relay_button() -> Button<'static> { +fn add_relay_button(i18n: &mut Localization) -> Button<'static> { Button::image_and_text( app_images::add_relay_image().fit_to_exact_size(Vec2::new(48.0, 48.0)), - RichText::new(tr!("Add relay", "Button label to add a relay")) + RichText::new(tr!(i18n, "Add relay", "Button label to add a relay")) .size(16.0) // TODO: this color should not be hard coded. Find some way to add it to the visuals .color(PINK), @@ -186,9 +196,9 @@ fn add_relay_button() -> Button<'static> { .frame(false) } -fn add_relay_button2(is_enabled: bool) -> impl egui::Widget + 'static { +fn add_relay_button2<'a>(i18n: &'a mut Localization, is_enabled: bool) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| -> egui::Response { - let add_text = tr!("Add", "Button label to add a relay"); + let add_text = tr!(i18n, "Add", "Button label to add a relay"); let button_widget = styled_button(add_text.as_str(), notedeck_ui::colors::PINK); ui.add_enabled(is_enabled, button_widget) } @@ -219,7 +229,7 @@ fn relay_frame(ui: &mut Ui) -> Frame { .stroke(ui.style().visuals.noninteractive().bg_stroke) } -fn show_connection_status(ui: &mut Ui, status: RelayStatus) { +fn show_connection_status(ui: &mut Ui, i18n: &mut Localization, status: RelayStatus) { let fg_color = match status { RelayStatus::Connected => ui.visuals().selection.bg_fill, RelayStatus::Connecting => ui.visuals().warn_fg_color, @@ -228,9 +238,11 @@ fn show_connection_status(ui: &mut Ui, status: RelayStatus) { let bg_color = egui::lerp(Rgba::from(fg_color)..=Rgba::BLACK, 0.8).into(); let label_text = match status { - RelayStatus::Connected => tr!("Connected", "Status label for connected relay"), - RelayStatus::Connecting => tr!("Connecting...", "Status label for connecting relay"), - RelayStatus::Disconnected => tr!("Not Connected", "Status label for disconnected relay"), + RelayStatus::Connected => tr!(i18n, "Connected", "Status label for connected relay"), + RelayStatus::Connecting => tr!(i18n, "Connecting...", "Status label for connecting relay"), + RelayStatus::Disconnected => { + tr!(i18n, "Not Connected", "Status label for disconnected relay") + } }; let frame = Frame::new() @@ -290,7 +302,7 @@ mod preview { fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { self.pool.try_recv(); let mut id_string_map = HashMap::new(); - RelayView::new(app.pool, &mut id_string_map).ui(ui); + RelayView::new(app.pool, &mut id_string_map, app.i18n).ui(ui); None } } diff --git a/crates/notedeck_columns/src/ui/search/mod.rs b/crates/notedeck_columns/src/ui/search/mod.rs @@ -5,7 +5,7 @@ use state::TypingType; use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; use egui_winit::clipboard::Clipboard; use nostrdb::{Filter, Ndb, Transaction}; -use notedeck::{tr, tr_plural, NoteAction, NoteContext, NoteRef}; +use notedeck::{tr, tr_plural, Localization, NoteAction, NoteContext, NoteRef}; use notedeck_ui::{ context_menu::{input_context, PasteBehavior}, icons::search_icon, @@ -54,6 +54,7 @@ impl<'a, 'd> SearchView<'a, 'd> { ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); let search_resp = search_box( + self.note_context.i18n, &mut self.query.string, self.query.focus_state.clone(), ui, @@ -120,6 +121,7 @@ impl<'a, 'd> SearchView<'a, 'd> { } SearchState::Searched => { ui.label(tr_plural!( + self.note_context.i18n, "Got {count} result for '{query}'", // one "Got {count} results for '{query}'", // other "Search results count", // comment @@ -130,6 +132,7 @@ impl<'a, 'd> SearchView<'a, 'd> { } SearchState::Typing(TypingType::AutoSearch) => { ui.label(tr!( + self.note_context.i18n, "Searching for '{query}'", "Search in progress message", query = &self.query.string @@ -247,6 +250,7 @@ impl SearchResponse { } fn search_box( + i18n: &mut Localization, input: &mut String, focus_state: FocusState, ui: &mut egui::Ui, @@ -290,6 +294,7 @@ fn search_box( TextEdit::singleline(input) .hint_text( RichText::new(tr!( + i18n, "Search notes...", "Placeholder for search notes input field" )) diff --git a/crates/notedeck_columns/src/ui/side_panel.rs b/crates/notedeck_columns/src/ui/side_panel.rs @@ -12,7 +12,7 @@ use crate::{ route::Route, }; -use notedeck::{tr, Accounts, UserAccount}; +use notedeck::{tr, Accounts, Localization, UserAccount}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, app_images, colors, View, @@ -26,6 +26,7 @@ static ICON_WIDTH: f32 = 40.0; pub struct DesktopSidePanel<'a> { selected_account: &'a UserAccount, decks_cache: &'a DecksCache, + i18n: &'a mut Localization, } impl View for DesktopSidePanel<'_> { @@ -58,10 +59,15 @@ impl SidePanelResponse { } impl<'a> DesktopSidePanel<'a> { - pub fn new(selected_account: &'a UserAccount, decks_cache: &'a DecksCache) -> Self { + pub fn new( + selected_account: &'a UserAccount, + decks_cache: &'a DecksCache, + i18n: &'a mut Localization, + ) -> Self { Self { selected_account, decks_cache, + i18n, } } @@ -105,9 +111,13 @@ impl<'a> DesktopSidePanel<'a> { ui.add_space(8.0); ui.add(egui::Label::new( - RichText::new(tr!("DECKS", "Label for decks section in side panel")) - .size(11.0) - .color(ui.visuals().noninteractive().fg_stroke.color), + RichText::new(tr!( + self.i18n, + "DECKS", + "Label for decks section in side panel" + )) + .size(11.0) + .color(ui.visuals().noninteractive().fg_stroke.color), )); ui.add_space(8.0); let add_deck_resp = ui.add(add_deck_button()); @@ -175,8 +185,9 @@ impl<'a> DesktopSidePanel<'a> { decks_cache: &mut DecksCache, accounts: &Accounts, action: SidePanelAction, + i18n: &mut Localization, ) -> Option<SwitchingAction> { - let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); + let router = get_active_columns_mut(i18n, accounts, decks_cache).get_first_router(); let mut switching_response = None; match action { /* @@ -218,7 +229,7 @@ impl<'a> DesktopSidePanel<'a> { { router.go_back(); } else { - get_active_columns_mut(accounts, decks_cache).new_column_picker(); + get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker(); } } SidePanelAction::ComposeNote => { @@ -263,7 +274,7 @@ impl<'a> DesktopSidePanel<'a> { switching_response = Some(crate::nav::SwitchingAction::Decks( DecksAction::Switch(index), )); - if let Some(edit_deck) = get_decks_mut(accounts, decks_cache) + if let Some(edit_deck) = get_decks_mut(i18n, accounts, decks_cache) .decks_mut() .get_mut(index) { diff --git a/crates/notedeck_columns/src/ui/support.rs b/crates/notedeck_columns/src/ui/support.rs @@ -1,5 +1,5 @@ use egui::{vec2, Button, Label, Layout, RichText}; -use notedeck::{tr, NamedFontFamily, NotedeckTextStyle}; +use notedeck::{tr, Localization, NamedFontFamily, NotedeckTextStyle}; use notedeck_ui::{colors::PINK, padding}; use tracing::error; @@ -7,11 +7,12 @@ use crate::support::Support; pub struct SupportView<'a> { support: &'a mut Support, + i18n: &'a mut Localization, } impl<'a> SupportView<'a> { - pub fn new(support: &'a mut Support) -> Self { - Self { support } + pub fn new(support: &'a mut Support, i18n: &'a mut Localization) -> Self { + Self { support, i18n } } pub fn show(&mut self, ui: &mut egui::Ui) { @@ -22,14 +23,24 @@ impl<'a> SupportView<'a> { egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), ); ui.add(Label::new( - RichText::new(tr!("Running into a bug?", "Heading for support section")).font(font), + RichText::new(tr!( + self.i18n, + "Running into a bug?", + "Heading for support section" + )) + .font(font), )); ui.label( - RichText::new(tr!("Step 1", "Step 1 label in support instructions")) - .text_style(NotedeckTextStyle::Heading3.text_style()), + RichText::new(tr!( + self.i18n, + "Step 1", + "Step 1 label in support instructions" + )) + .text_style(NotedeckTextStyle::Heading3.text_style()), ); padding(8.0, ui, |ui| { ui.label(tr!( + self.i18n, "Open your default email client to get help from the Damus team", "Instruction to open email client" )); @@ -37,7 +48,7 @@ impl<'a> SupportView<'a> { ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { let font_size = notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); - let button_resp = ui.add(open_email_button(font_size, size)); + let button_resp = ui.add(open_email_button(self.i18n, font_size, size)); if button_resp.clicked() { if let Err(e) = open::that(self.support.get_mailto_url()) { error!( @@ -55,19 +66,23 @@ impl<'a> SupportView<'a> { if let Some(logs) = self.support.get_most_recent_log() { ui.label( - RichText::new(tr!("Step 2", "Step 2 label in support instructions")) - .text_style(NotedeckTextStyle::Heading3.text_style()), + RichText::new(tr!( + self.i18n, + "Step 2", + "Step 2 label in support instructions" + )) + .text_style(NotedeckTextStyle::Heading3.text_style()), ); let size = vec2(80.0, 40.0); let copy_button = Button::new( - RichText::new(tr!("Copy", "Button label to copy logs")).size( + RichText::new(tr!(self.i18n, "Copy", "Button label to copy logs")).size( notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body), ), ) .fill(PINK) .min_size(size); padding(8.0, ui, |ui| { - ui.add(Label::new(RichText::new(tr!("Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.", "Instruction for copying logs"))).wrap()); + ui.add(Label::new(RichText::new(tr!(self.i18n,"Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.", "Instruction for copying logs"))).wrap()); ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { if ui.add(copy_button).clicked() { ui.ctx().copy_text(logs.to_string()); @@ -86,9 +101,13 @@ impl<'a> SupportView<'a> { } } -fn open_email_button(font_size: f32, size: egui::Vec2) -> impl egui::Widget { +fn open_email_button( + i18n: &mut Localization, + font_size: f32, + size: egui::Vec2, +) -> impl egui::Widget { Button::new( - RichText::new(tr!("Open Email", "Button label to open email client")).size(font_size), + RichText::new(tr!(i18n, "Open Email", "Button label to open email client")).size(font_size), ) .fill(PINK) .min_size(size) diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -8,7 +8,9 @@ use std::f32::consts::PI; use tracing::{error, warn}; use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter}; -use notedeck::{note::root_note_id_from_selected_id, tr, NoteAction, NoteContext, ScrollInfo}; +use notedeck::{ + note::root_note_id_from_selected_id, tr, Localization, NoteAction, NoteContext, ScrollInfo, +}; use notedeck_ui::{ anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, NoteOptions, NoteView, @@ -103,7 +105,12 @@ fn timeline_ui( return None; }; - timeline.selected_view = tabs_ui(ui, timeline.selected_view, &timeline.views); + timeline.selected_view = tabs_ui( + ui, + note_context.i18n, + timeline.selected_view, + &timeline.views, + ); // need this for some reason?? ui.add_space(3.0); @@ -263,7 +270,12 @@ fn goto_top_button(center: Pos2) -> impl egui::Widget { } } -pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usize { +pub fn tabs_ui( + ui: &mut egui::Ui, + i18n: &mut Localization, + selected: usize, + views: &[TimelineTab], +) -> usize { ui.spacing_mut().item_spacing.y = 0.0; let tab_res = egui_tabs::Tabs::new(views.len() as i32) @@ -281,9 +293,13 @@ pub fn tabs_ui(ui: &mut egui::Ui, selected: usize, views: &[TimelineTab]) -> usi let ind = state.index(); let txt = match views[ind as usize].filter { - ViewFilter::Notes => tr!("Notes", "Label for notes-only filter"), + ViewFilter::Notes => tr!(i18n, "Notes", "Label for notes-only filter"), ViewFilter::NotesAndReplies => { - tr!("Notes & Replies", "Label for notes and replies filter") + tr!( + i18n, + "Notes & Replies", + "Label for notes and replies filter" + ) } }; diff --git a/crates/notedeck_columns/src/ui/wallet.rs b/crates/notedeck_columns/src/ui/wallet.rs @@ -1,7 +1,7 @@ use egui::{vec2, CornerRadius, Layout}; use notedeck::{ - get_current_wallet, tr, Accounts, DefaultZapMsats, GlobalWallet, NotedeckTextStyle, - PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet, + get_current_wallet, tr, Accounts, DefaultZapMsats, GlobalWallet, Localization, + NotedeckTextStyle, PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet, }; use crate::{nav::RouterAction, route::Route}; @@ -153,11 +153,12 @@ impl WalletAction { pub struct WalletView<'a> { state: WalletState<'a>, + i18n: &'a mut Localization, } impl<'a> WalletView<'a> { - pub fn new(state: WalletState<'a>) -> Self { - Self { state } + pub fn new(state: WalletState<'a>, i18n: &'a mut Localization) -> Self { + Self { state, i18n } } pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<WalletAction> { @@ -173,11 +174,17 @@ impl<'a> WalletView<'a> { wallet, default_zap_state, can_create_local_wallet, - } => show_with_wallet(ui, wallet, default_zap_state, *can_create_local_wallet), + } => show_with_wallet( + ui, + self.i18n, + wallet, + default_zap_state, + *can_create_local_wallet, + ), WalletState::NoWallet { state, show_local_only, - } => show_no_wallet(ui, state, *show_local_only), + } => show_no_wallet(ui, self.i18n, state, *show_local_only), } } } @@ -196,6 +203,7 @@ fn try_create_wallet(state: &mut WalletUIState) -> Option<Wallet> { fn show_no_wallet( ui: &mut egui::Ui, + i18n: &mut Localization, state: &mut WalletUIState, show_local_only: bool, ) -> Option<WalletAction> { @@ -203,6 +211,7 @@ fn show_no_wallet( let text_edit = egui::TextEdit::singleline(&mut state.buf) .hint_text( egui::RichText::new(tr!( + i18n, "Paste your NWC URI here...", "Placeholder text for NWC URI input" )) @@ -222,10 +231,12 @@ fn show_no_wallet( let error_str = match error_msg { WalletError::InvalidURI => tr!( + i18n, "Invalid NWC URI", "Error message for invalid Nostr Wallet Connect URI" ), WalletError::NoWallet => tr!( + i18n, "Add a wallet to continue", "Error message for missing wallet" ), @@ -239,6 +250,7 @@ fn show_no_wallet( ui.checkbox( &mut state.for_local_only, tr!( + i18n, "Use this wallet for the current account only", "Checkbox label for using wallet only for current account" ), @@ -248,7 +260,7 @@ fn show_no_wallet( ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { ui.add(styled_button( - tr!("Add Wallet", "Button label to add a wallet").as_str(), + tr!(i18n, "Add Wallet", "Button label to add a wallet").as_str(), notedeck_ui::colors::PINK, )) .clicked() @@ -259,6 +271,7 @@ fn show_no_wallet( fn show_with_wallet( ui: &mut egui::Ui, + i18n: &mut Localization, wallet: &mut Wallet, default_zap_state: &mut DefaultZapState, can_create_local_wallet: bool, @@ -279,12 +292,12 @@ fn show_with_wallet( } }); - let mut action = show_default_zap(ui, default_zap_state); + let mut action = show_default_zap(ui, i18n, default_zap_state); ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| 's: { if ui .add(styled_button( - tr!("Delete Wallet", "Button label to delete a wallet").as_str(), + tr!(i18n, "Delete Wallet", "Button label to delete a wallet").as_str(), ui.visuals().window_fill, )) .clicked() @@ -299,6 +312,7 @@ fn show_with_wallet( .checkbox( &mut false, tr!( + i18n, "Add a different wallet that will only be used for this account", "Button label to add a different wallet" ), @@ -323,13 +337,17 @@ fn show_balance(ui: &mut egui::Ui, msats: u64) -> egui::Response { .inner } -fn show_default_zap(ui: &mut egui::Ui, state: &mut DefaultZapState) -> Option<WalletAction> { +fn show_default_zap( + ui: &mut egui::Ui, + i18n: &mut Localization, + state: &mut DefaultZapState, +) -> Option<WalletAction> { let mut action = None; ui.allocate_ui_with_layout( vec2(ui.available_width(), 50.0), egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true), |ui| { - ui.label(tr!("Default amount per zap: ", "Label for default zap amount input")); + ui.label(tr!(i18n, "Default amount per zap: ", "Label for default zap amount input")); match state { DefaultZapState::Pending(pending_default_zap_state) => { let text = &mut pending_default_zap_state.amount_sats; @@ -361,27 +379,27 @@ fn show_default_zap(ui: &mut egui::Ui, state: &mut DefaultZapState) -> Option<Wa ui.memory_mut(|m| m.request_focus(id)); - ui.label(tr!("sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.")); + ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.")); if ui - .add(styled_button(tr!("Save", "Button to save default zap amount").as_str(), ui.visuals().widgets.active.bg_fill)) + .add(styled_button(tr!(i18n, "Save", "Button to save default zap amount").as_str(), ui.visuals().widgets.active.bg_fill)) .clicked() { action = Some(WalletAction::SetDefaultZapSats(text.to_string())); } } DefaultZapState::Valid(msats) => { - if let Some(wallet_action) = show_valid_msats(ui, **msats) { + if let Some(wallet_action) = show_valid_msats(ui, i18n, **msats) { action = Some(wallet_action); } - ui.label(tr!("sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.")); + ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.")); } } if let DefaultZapState::Pending(pending) = state { if let Some(error_message) = &pending.error_message { let msg_str = match error_message { - notedeck::DefaultZapError::InvalidUserInput => tr!("Invalid amount", "Error message for invalid zap amount"), + notedeck::DefaultZapError::InvalidUserInput => tr!(i18n, "Invalid amount", "Error message for invalid zap amount"), }; ui.colored_label(ui.visuals().warn_fg_color, msg_str); @@ -393,7 +411,11 @@ fn show_default_zap(ui: &mut egui::Ui, state: &mut DefaultZapState) -> Option<Wa action } -fn show_valid_msats(ui: &mut egui::Ui, msats: u64) -> Option<WalletAction> { +fn show_valid_msats( + ui: &mut egui::Ui, + i18n: &mut Localization, + msats: u64, +) -> Option<WalletAction> { let galley = { let painter = ui.painter(); @@ -409,7 +431,11 @@ fn show_valid_msats(ui: &mut egui::Ui, msats: u64) -> Option<WalletAction> { let resp = resp .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text_at_pointer(tr!("Click to edit", "Hover text for editable zap amount")); + .on_hover_text_at_pointer(tr!( + i18n, + "Click to edit", + "Hover text for editable zap amount" + )); let painter = ui.painter_at(resp.rect); diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -4,7 +4,7 @@ use crate::{ }; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use nostrdb::{Ndb, Transaction}; -use notedeck::{tr, Accounts, AppContext, Images, NoteAction, NoteContext}; +use notedeck::{tr, Accounts, AppContext, Images, Localization, NoteAction, NoteContext}; use notedeck_ui::{app_images, icons::search_icon, jobs::JobsCache, NoteOptions, ProfilePic}; /// DaveUi holds all of the data it needs to render itself @@ -107,7 +107,7 @@ impl<'a> DaveUi<'a> { .inner_margin(egui::Margin::same(8)) .fill(ui.visuals().extreme_bg_color) .corner_radius(12.0) - .show(ui, |ui| self.inputbox(ui)) + .show(ui, |ui| self.inputbox(app_ctx.i18n, ui)) .inner; let note_action = egui::ScrollArea::vertical() @@ -134,11 +134,11 @@ impl<'a> DaveUi<'a> { .or(DaveResponse { action }) } - fn error_chat(&self, err: &str, ui: &mut egui::Ui) { + fn error_chat(&self, i18n: &mut Localization, err: &str, ui: &mut egui::Ui) { if self.trial { ui.add(egui::Label::new( egui::RichText::new( - tr!("The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!", "Message shown when Dave trial period has ended"), + tr!(i18n, "The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!", "Message shown when Dave trial period has ended"), ) .weak(), )); @@ -160,7 +160,7 @@ impl<'a> DaveUi<'a> { for message in self.chat { let r = match message { Message::Error(err) => { - self.error_chat(err, ui); + self.error_chat(ctx.i18n, err, ui); None } Message::User(msg) => { @@ -220,6 +220,7 @@ impl<'a> DaveUi<'a> { unknown_ids: ctx.unknown_ids, clipboard: ctx.clipboard, current_account_has_wallet: false, + i18n: ctx.i18n, }; let txn = Transaction::new(note_context.ndb).unwrap(); @@ -303,13 +304,14 @@ impl<'a> DaveUi<'a> { note_action } - fn inputbox(&mut self, ui: &mut egui::Ui) -> DaveResponse { + fn inputbox(&mut self, i18n: &mut Localization, ui: &mut egui::Ui) -> DaveResponse { //ui.add_space(Self::chat_margin(ui.ctx()) as f32); ui.horizontal(|ui| { ui.with_layout(Layout::right_to_left(Align::Max), |ui| { let mut dave_response = DaveResponse::none(); if ui .add(egui::Button::new(tr!( + i18n, "Ask", "Button to send message to Dave AI assistant" ))) @@ -330,6 +332,7 @@ impl<'a> DaveUi<'a> { )) .hint_text( egui::RichText::new(tr!( + i18n, "Ask dave anything...", "Placeholder text for Dave AI input field" )) diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -323,6 +323,7 @@ pub fn render_note_contents( &supported_medias, carousel_id, trusted_media, + note_context.i18n, ); ui.add_space(2.0); } diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -1,6 +1,6 @@ use egui::{Rect, Vec2}; use nostrdb::NoteKey; -use notedeck::{tr, BroadcastContext, NoteContextSelection}; +use notedeck::{tr, BroadcastContext, Localization, NoteContextSelection}; pub struct NoteContextButton { put_at: Option<Rect>, @@ -105,24 +105,17 @@ impl NoteContextButton { #[profiling::function] pub fn menu( ui: &mut egui::Ui, + i18n: &mut Localization, button_response: egui::Response, ) -> Option<NoteContextSelection> { let mut context_selection: Option<NoteContextSelection> = None; - // Debug: Check if global i18n is available - if let Some(i18n) = notedeck::i18n::get_global_i18n() { - if let Ok(locale) = i18n.get_current_locale() { - tracing::debug!("Current locale in context menu: {}", locale); - } - } else { - tracing::warn!("Global i18n context not available in context menu"); - } - stationary_arbitrary_menu_button(ui, button_response, |ui| { ui.set_max_width(200.0); // Debug: Check what the tr! macro returns let copy_text = tr!( + i18n, "Copy Text", "Copy the text content of the note to clipboard" ); @@ -134,6 +127,7 @@ impl NoteContextButton { } if ui .button(tr!( + i18n, "Copy Pubkey", "Copy the author's public key to clipboard" )) @@ -144,6 +138,7 @@ impl NoteContextButton { } if ui .button(tr!( + i18n, "Copy Note ID", "Copy the unique note identifier to clipboard" )) @@ -154,6 +149,7 @@ impl NoteContextButton { } if ui .button(tr!( + i18n, "Copy Note JSON", "Copy the raw note data in JSON format to clipboard" )) @@ -164,6 +160,7 @@ impl NoteContextButton { } if ui .button(tr!( + i18n, "Broadcast", "Broadcast the note to all connected relays" )) @@ -176,6 +173,7 @@ impl NoteContextButton { } if ui .button(tr!( + i18n, "Broadcast Local", "Broadcast the note only to local network relays" )) diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -6,8 +6,8 @@ use egui::{ }; use notedeck::{ fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url, - tr, GifState, GifStateMap, Images, JobPool, MediaCache, MediaCacheType, NotedeckTextStyle, - TexturedImage, TexturesCache, UrlMimes, + tr, GifState, GifStateMap, Images, JobPool, Localization, MediaCache, MediaCacheType, + NotedeckTextStyle, TexturedImage, TexturesCache, UrlMimes, }; use crate::{ @@ -20,6 +20,7 @@ use crate::{ AnimationHelper, PulseAlpha, }; +#[allow(clippy::too_many_arguments)] pub(crate) fn image_carousel( ui: &mut egui::Ui, img_cache: &mut Images, @@ -28,6 +29,7 @@ pub(crate) fn image_carousel( medias: &[RenderableMedia], carousel_id: egui::Id, trusted_media: bool, + i18n: &mut Localization, ) -> Option<MediaAction> { // let's make sure everything is within our area @@ -69,9 +71,14 @@ pub(crate) fn image_carousel( blur_type.clone(), ); - if let Some(cur_action) = - render_media(ui, &mut img_cache.gif_states, media_state, url, height) - { + if let Some(cur_action) = render_media( + ui, + &mut img_cache.gif_states, + media_state, + url, + height, + i18n, + ) { // clicked the media, lets set the active index if let MediaUIAction::Clicked = cur_action { set_show_popup(ui, popup_id(carousel_id), true); @@ -100,7 +107,14 @@ pub(crate) fn image_carousel( let current_image_index = update_selected_image_index(ui, carousel_id, medias.len() as i32); - show_full_screen_media(ui, medias, current_image_index, img_cache, carousel_id); + show_full_screen_media( + ui, + medias, + current_image_index, + img_cache, + carousel_id, + i18n, + ); } action } @@ -163,6 +177,7 @@ fn show_full_screen_media( index: usize, img_cache: &mut Images, carousel_id: egui::Id, + i18n: &mut Localization, ) { Window::new("image_popup") .title_bar(false) @@ -201,6 +216,7 @@ fn show_full_screen_media( cur_state.gifs, image_url, carousel_id, + i18n, ); }) }); @@ -363,6 +379,7 @@ fn select_next_media( next as usize } +#[allow(clippy::too_many_arguments)] fn render_full_screen_media( ui: &mut egui::Ui, num_urls: usize, @@ -371,6 +388,7 @@ fn render_full_screen_media( gifs: &mut HashMap<String, GifState>, image_url: &str, carousel_id: egui::Id, + i18n: &mut Localization, ) { const TOP_BAR_HEIGHT: f32 = 30.0; const BOTTOM_BAR_HEIGHT: f32 = 60.0; @@ -631,13 +649,17 @@ fn render_full_screen_media( }); } - copy_link(image_url, &response); + copy_link(i18n, image_url, &response); } -fn copy_link(url: &str, img_resp: &Response) { +fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) { img_resp.context_menu(|ui| { if ui - .button(tr!("Copy Link", "Button to copy media link to clipboard")) + .button(tr!( + i18n, + "Copy Link", + "Button to copy media link to clipboard" + )) .clicked() { ui.ctx().copy_text(url.to_owned()); @@ -653,10 +675,11 @@ fn render_media( render_state: MediaRenderState, url: &str, height: f32, + i18n: &mut Localization, ) -> Option<MediaUIAction> { match render_state { MediaRenderState::ActualImage(image) => { - if render_success_media(ui, url, image, gifs, height).clicked() { + if render_success_media(ui, url, image, gifs, height, i18n).clicked() { Some(MediaUIAction::Clicked) } else { None @@ -695,9 +718,9 @@ fn render_media( let resp = match obfuscated_texture { ObfuscatedTexture::Blur(texture_handle) => { let resp = ui.add(texture_to_image(texture_handle, height)); - render_blur_text(ui, url, resp.rect) + render_blur_text(ui, i18n, url, resp.rect) } - ObfuscatedTexture::Default => render_default_blur(ui, height, url), + ObfuscatedTexture::Default => render_default_blur(ui, i18n, height, url), }; if resp @@ -712,7 +735,12 @@ fn render_media( } } -fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> egui::Response { +fn render_blur_text( + ui: &mut egui::Ui, + i18n: &mut Localization, + url: &str, + render_rect: egui::Rect, +) -> egui::Response { let helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect); let painter = ui.painter_at(helper.get_animation_rect()); @@ -726,6 +754,7 @@ fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> eg ); let info_galley = painter.layout( tr!( + i18n, "Media from someone you don't follow", "Text shown on blurred media from unfollowed users" ) @@ -736,7 +765,7 @@ fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> eg ); let load_galley = painter.layout_no_wrap( - tr!("Tap to Load", "Button text to load blurred media").to_owned(), + tr!(i18n, "Tap to Load", "Button text to load blurred media"), animation_fontid, egui::Color32::BLACK, // ui.visuals().widgets.inactive.bg_fill, @@ -792,9 +821,14 @@ fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> eg helper.take_animation_response() } -fn render_default_blur(ui: &mut egui::Ui, height: f32, url: &str) -> egui::Response { +fn render_default_blur( + ui: &mut egui::Ui, + i18n: &mut Localization, + height: f32, + url: &str, +) -> egui::Response { let rect = render_default_blur_bg(ui, height, url, false); - render_blur_text(ui, url, rect) + render_blur_text(ui, i18n, url, rect) } fn render_default_blur_bg(ui: &mut egui::Ui, height: f32, url: &str, shimmer: bool) -> egui::Rect { @@ -883,12 +917,13 @@ fn render_success_media( tex: &mut TexturedImage, gifs: &mut GifStateMap, height: f32, + i18n: &mut Localization, ) -> Response { let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex)); let img = texture_to_image(texture, height); let img_resp = ui.add(Button::image(img).frame(false)); - copy_link(url, &img_resp); + copy_link(i18n, url, &img_resp); img_resp } diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -17,6 +17,7 @@ use notedeck::note::MediaAction; use notedeck::note::ZapTargetAmount; use notedeck::ui::is_narrow; use notedeck::Images; +use notedeck::Localization; pub use options::NoteOptions; pub use reply_description::reply_desc; @@ -27,8 +28,8 @@ use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction}; use notedeck::{ name::get_display_name, note::{NoteAction, NoteContext, ZapAction}, - tr, AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned, - NotedeckTextStyle, ZapTarget, Zaps, + tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle, + ZapTarget, Zaps, }; pub struct NoteView<'a, 'd> { @@ -194,7 +195,6 @@ impl<'a, 'd> NoteView<'a, 'd> { } fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { - let note_key = self.note.key().expect("todo: implement non-db notes"); let txn = self.note.txn().expect("todo: implement non-db notes"); ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { @@ -206,23 +206,22 @@ impl<'a, 'd> NoteView<'a, 'd> { //ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - let cached_note = self - .note_context - .note_cache - .cached_note_or_insert_mut(note_key, self.note); - let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { - render_reltime(ui, cached_note, false).response + render_reltime(ui, self.note_context.i18n, self.note.created_at(), false).response }); let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { ui.add( - Username::new(profile.as_ref().ok(), self.note.pubkey()) - .abbreviated(6) - .pk_colored(true), + Username::new( + self.note_context.i18n, + profile.as_ref().ok(), + self.note.pubkey(), + ) + .abbreviated(6) + .pk_colored(true), ) }); @@ -308,9 +307,13 @@ impl<'a, 'd> NoteView<'a, 'd> { let color = ui.style().visuals.noninteractive().fg_stroke.color; ui.add_space(4.0); ui.label( - RichText::new(tr!("Reposted", "Label for reposted notes")) - .color(color) - .text_style(style.text_style()), + RichText::new(tr!( + self.note_context.i18n, + "Reposted", + "Label for reposted notes" + )) + .color(color) + .text_style(style.text_style()), ); }); NoteView::new(self.note_context, &note_to_repost, self.flags, self.jobs).show(ui) @@ -348,20 +351,17 @@ impl<'a, 'd> NoteView<'a, 'd> { #[profiling::function] fn note_header( ui: &mut egui::Ui, - note_cache: &mut NoteCache, + i18n: &mut Localization, note: &Note, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, show_unread_indicator: bool, ) { - let note_key = note.key().unwrap(); - let horiz_resp = ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; - ui.add(Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); + ui.add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20)); - let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); - render_reltime(ui, cached_note, true); + render_reltime(ui, i18n, note.created_at(), true); }) .response; @@ -405,7 +405,7 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.horizontal_centered(|ui| { NoteView::note_header( ui, - self.note_context.note_cache, + self.note_context.i18n, self.note, profile, self.show_unread_indicator, @@ -460,10 +460,16 @@ impl<'a, 'd> NoteView<'a, 'd> { cur_acc: cur_acc.keypair(), }) }; - note_action = - render_note_actionbar(ui, zapper, self.note.id(), self.note.pubkey(), note_key) - .inner - .or(note_action); + note_action = render_note_actionbar( + ui, + zapper, + self.note.id(), + self.note.pubkey(), + note_key, + self.note_context.i18n, + ) + .inner + .or(note_action); } NoteUiResponse { @@ -489,7 +495,7 @@ impl<'a, 'd> NoteView<'a, 'd> { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { NoteView::note_header( ui, - self.note_context.note_cache, + self.note_context.i18n, self.note, profile, self.show_unread_indicator, @@ -542,6 +548,7 @@ impl<'a, 'd> NoteView<'a, 'd> { self.note.id(), self.note.pubkey(), note_key, + self.note_context.i18n, ) .inner .or(note_action); @@ -588,7 +595,8 @@ impl<'a, 'd> NoteView<'a, 'd> { }; let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); - if let Some(action) = NoteContextButton::menu(ui, resp.clone()) { + if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone()) + { note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); } } @@ -765,11 +773,13 @@ fn render_note_actionbar( note_id: &[u8; 32], note_pubkey: &[u8; 32], note_key: NoteKey, + i18n: &mut Localization, ) -> egui::InnerResponse<Option<NoteAction>> { ui.horizontal(|ui| 's: { - let reply_resp = reply_button(ui, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); + let reply_resp = + reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); let quote_resp = - quote_repost_button(ui, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); + quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); let to_noteid = |id: &[u8; 32]| NoteId::new(*id); if reply_resp.clicked() { @@ -804,7 +814,7 @@ fn render_note_actionbar( cur_acc.secret_key.as_ref()?; match zap_state { - Ok(any_zap_state) => ui.add(zap_button(any_zap_state, note_id)), + Ok(any_zap_state) => ui.add(zap_button(i18n, any_zap_state, note_id)), Err(err) => { let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); @@ -832,7 +842,8 @@ fn render_note_actionbar( #[profiling::function] fn render_reltime( ui: &mut egui::Ui, - note_cache: &mut CachedNote, + i18n: &mut Localization, + created_at: u64, before: bool, ) -> egui::InnerResponse<()> { ui.horizontal(|ui| { @@ -840,7 +851,7 @@ fn render_reltime( secondary_label(ui, "⋅"); } - secondary_label(ui, note_cache.reltime_str_mut()); + secondary_label(ui, notedeck::time_ago_since(i18n, created_at)); if !before { secondary_label(ui, "⋅"); @@ -848,7 +859,7 @@ fn render_reltime( }) } -fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { +fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response { let img = if ui.style().visuals.dark_mode { app_images::reply_dark_image() } else { @@ -862,9 +873,11 @@ fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); - let put_resp = ui - .put(rect, img.max_width(size)) - .on_hover_text(tr!("Reply to this note", "Hover text for reply button")); + let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( + i18n, + "Reply to this note", + "Hover text for reply button" + )); resp.union(put_resp) } @@ -877,7 +890,11 @@ fn repost_icon(dark_mode: bool) -> egui::Image<'static> { } } -fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { +fn quote_repost_button( + ui: &mut egui::Ui, + i18n: &mut Localization, + note_key: NoteKey, +) -> egui::Response { let size = 14.0; let expand_size = 5.0; let anim_speed = 0.05; @@ -889,12 +906,20 @@ fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { let put_resp = ui .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)) - .on_hover_text(tr!("Repost this note", "Hover text for repost button")); + .on_hover_text(tr!( + i18n, + "Repost this note", + "Hover text for repost button" + )); resp.union(put_resp) } -fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use<'_> { +fn zap_button<'a>( + i18n: &'a mut Localization, + state: AnyZapState, + noteid: &'a [u8; 32], +) -> impl egui::Widget + use<'a> { move |ui: &mut egui::Ui| -> egui::Response { let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); @@ -927,9 +952,11 @@ fn zap_button(state: AnyZapState, noteid: &[u8; 32]) -> impl egui::Widget + use< let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); - let put_resp = ui - .put(rect, img) - .on_hover_text(tr!("Zap this note", "Hover text for zap button")); + let put_resp = ui.put(rect, img).on_hover_text(tr!( + i18n, + "Zap this note", + "Hover text for zap button" + )); resp.union(put_resp) } diff --git a/crates/notedeck_ui/src/note/reply_description.rs b/crates/notedeck_ui/src/note/reply_description.rs @@ -130,9 +130,13 @@ fn render_text_segments( if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) { let r = ui.add( Label::new( - RichText::new(tr!("note", "Link text for note references")) - .size(size) - .color(link_color), + RichText::new(tr!( + note_context.i18n, + "note", + "Link text for note references" + )) + .size(size) + .color(link_color), ) .sense(Sense::click()) .selectable(selectable), @@ -157,9 +161,13 @@ fn render_text_segments( if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) { let r = ui.add( Label::new( - RichText::new(tr!("thread", "Link text for thread references")) - .size(size) - .color(link_color), + RichText::new(tr!( + note_context.i18n, + "thread", + "Link text for thread references" + )) + .size(size) + .color(link_color), ) .sense(Sense::click()) .selectable(selectable), @@ -206,6 +214,7 @@ pub fn reply_desc( } else { // Handle case where reply note is not found let template = tr!( + note_context.i18n, "replying to a note", "Fallback text when reply note is not found" ); @@ -225,6 +234,7 @@ pub fn reply_desc( let segments = if note_reply.is_reply_to_root() { // Template: "replying to {user}'s {thread}" let template = tr!( + note_context.i18n, "replying to {user}'s {thread}", "Template for replying to root thread", user = "{user}", @@ -243,6 +253,7 @@ pub fn reply_desc( if root_note.pubkey() == reply_note.pubkey() { // Template: "replying to {user}'s {note}" let template = tr!( + note_context.i18n, "replying to {user}'s {note}", "Template for replying to user's note", user = "{user}", @@ -254,6 +265,7 @@ pub fn reply_desc( // Template: "replying to {reply_user}'s {note} in {thread_user}'s {thread}" // This would need more sophisticated placeholder handling let template = tr!( + note_context.i18n, "replying to {user}'s {note} in {thread_user}'s {thread}", "Template for replying to note in different user's thread", user = "{user}", @@ -273,6 +285,7 @@ pub fn reply_desc( } else { // Template: "replying to {user} in someone's thread" let template = tr!( + note_context.i18n, "replying to {user} in someone's thread", "Template for replying to user in unknown thread", user = "{user}" @@ -283,6 +296,7 @@ pub fn reply_desc( } else { // Fallback let template = tr!( + note_context.i18n, "replying to {user}", "Fallback template for replying to user", user = "{user}" diff --git a/crates/notedeck_ui/src/profile/preview.rs b/crates/notedeck_ui/src/profile/preview.rs @@ -3,7 +3,9 @@ use egui::{Frame, Label, RichText}; use egui_extras::Size; use nostrdb::ProfileRecord; -use notedeck::{name::get_display_name, profile::get_profile_url, tr, Images, NotedeckTextStyle}; +use notedeck::{ + name::get_display_name, profile::get_profile_url, tr, Images, Localization, NotedeckTextStyle, +}; use super::{about_section_widget, banner, display_name_widget}; @@ -68,6 +70,7 @@ impl egui::Widget for ProfilePreview<'_, '_> { pub struct SimpleProfilePreview<'a, 'cache> { profile: Option<&'a ProfileRecord<'a>>, + pub i18n: &'cache mut Localization, cache: &'cache mut Images, is_nsec: bool, } @@ -76,12 +79,14 @@ impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { pub fn new( profile: Option<&'a ProfileRecord<'a>>, cache: &'cache mut Images, + i18n: &'cache mut Localization, is_nsec: bool, ) -> Self { SimpleProfilePreview { profile, cache, is_nsec, + i18n, } } } @@ -96,12 +101,16 @@ impl egui::Widget for SimpleProfilePreview<'_, '_> { if !self.is_nsec { ui.add( Label::new( - RichText::new(tr!("Read only", "Label for read-only profile mode")) - .size(notedeck::fonts::get_font_size( - ui.ctx(), - &NotedeckTextStyle::Tiny, - )) - .color(ui.visuals().warn_fg_color), + RichText::new(tr!( + self.i18n, + "Read only", + "Label for read-only profile mode" + )) + .size(notedeck::fonts::get_font_size( + ui.ctx(), + &NotedeckTextStyle::Tiny, + )) + .color(ui.visuals().warn_fg_color), ) .selectable(false), ); diff --git a/crates/notedeck_ui/src/username.rs b/crates/notedeck_ui/src/username.rs @@ -1,8 +1,9 @@ use egui::{Color32, RichText, Widget}; use nostrdb::ProfileRecord; -use notedeck::{fonts::NamedFontFamily, tr}; +use notedeck::{fonts::NamedFontFamily, tr, Localization}; pub struct Username<'a> { + i18n: &'a mut Localization, profile: Option<&'a ProfileRecord<'a>>, pk: &'a [u8; 32], pk_colored: bool, @@ -20,10 +21,15 @@ impl<'a> Username<'a> { self } - pub fn new(profile: Option<&'a ProfileRecord>, pk: &'a [u8; 32]) -> Self { + pub fn new( + i18n: &'a mut Localization, + profile: Option<&'a ProfileRecord>, + pk: &'a [u8; 32], + ) -> Self { let pk_colored = false; let abbrev: usize = 1000; Username { + i18n, profile, pk, pk_colored, @@ -53,6 +59,7 @@ impl Widget for Username<'_> { } } else { let mut txt = RichText::new(tr!( + self.i18n, "nostrich", "Default username when profile is not available" ))