commit 2b48a20ccdc65e40f32cd4f75e1a46ae36d5ff93
parent b3bd68db3d689246c0f9d0790a8ba181ee803a77
Author: William Casarin <jb55@jb55.com>
Date: Tue, 22 Jul 2025 13:49:37 -0700
Merge initial i18n app translations from terry! #907
Terry Yiu (6):
Add Fluent-based localization manager and add script to export source strings for translations
Internationalize user-facing strings and export them for translations
Clean up time_ago_since, add tests, and internationalize strings
Add localization documentation to notedeck DEVELOPER.md
Fix export_source_strings.py to adjust for tr! and tr_plural! macro signature changes
Add French, German, Simplified Chinese, and Traditional Chinese translations
William Casarin (7):
i18n: make localization context non-global
i18n: always have en-XA available
args: add --locale option
debug: add startup query debug log
i18n: disable bidi for tests
i18n: disable broken tests for now
Diffstat:
59 files changed, 6790 insertions(+), 871 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -204,7 +204,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -377,7 +377,7 @@ checksum = "0289cba6d5143bfe8251d57b4a8cac036adf158525a76533a7082ba65ec76398"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -407,7 +407,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -442,7 +442,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -490,9 +490,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
-version = "1.4.0"
+version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "av1-grain"
@@ -510,9 +510,9 @@ dependencies = [
[[package]]
name = "avif-serialize"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e"
+checksum = "19135c0c7a60bfee564dbe44ab5ce0557c6bf3884e5291a50be76a15640c4fbd"
dependencies = [
"arrayvec",
]
@@ -614,15 +614,15 @@ dependencies = [
"regex",
"rustc-hash 1.1.0",
"shlex",
- "syn 2.0.103",
+ "syn 2.0.104",
"which",
]
[[package]]
name = "bip39"
-version = "2.1.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387"
+checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054"
dependencies = [
"bitcoin_hashes 0.13.0",
"serde",
@@ -810,9 +810,9 @@ checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
[[package]]
name = "bumpalo"
-version = "3.18.1"
+version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytemuck"
@@ -831,7 +831,7 @@ checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -983,6 +983,12 @@ dependencies = [
]
[[package]]
+name = "chunky-vec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb7bdea464ae038f09197b82430b921c53619fc8d2bcaf7b151013b3ca008017"
+
+[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1194,7 +1200,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1205,7 +1211,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1247,7 +1253,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1257,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1334,7 +1340,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1554,6 +1560,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
+name = "elsa"
+version = "1.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
name = "emath"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1613,7 +1628,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1634,7 +1649,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1645,7 +1660,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1697,7 +1712,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1708,12 +1723,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
-version = "0.3.12"
+version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
+checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -1826,6 +1841,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
[[package]]
+name = "fluent"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477"
+dependencies = [
+ "fluent-bundle",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-bundle"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4"
+dependencies = [
+ "fluent-langneg",
+ "fluent-syntax",
+ "intl-memoizer",
+ "intl_pluralrules",
+ "rustc-hash 2.1.1",
+ "self_cell",
+ "smallvec",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-fallback"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38637647e8853f0bae81ffb20f53b2b3b60fec70ab30ad8a84583682fc02629b"
+dependencies = [
+ "async-trait",
+ "chunky-vec",
+ "fluent-bundle",
+ "futures",
+ "once_cell",
+ "pin-cell",
+ "rustc-hash 2.1.1",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-langneg"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94"
+dependencies = [
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-resmgr"
+version = "0.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5579ef08073c77fe7622558d04d56fa548419c81dfd31d549eb5dff9102cc0c3"
+dependencies = [
+ "elsa",
+ "fluent-bundle",
+ "fluent-fallback",
+ "futures",
+ "rustc-hash 2.1.1",
+ "thiserror 2.0.12",
+ "unic-langid",
+]
+
+[[package]]
+name = "fluent-syntax"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198"
+dependencies = [
+ "memchr",
+ "thiserror 2.0.12",
+]
+
+[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1855,7 +1946,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -1942,7 +2033,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -2030,9 +2121,9 @@ dependencies = [
[[package]]
name = "gif"
-version = "0.13.1"
+version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
@@ -2354,7 +2445,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
- "webpki-roots 1.0.0",
+ "webpki-roots 1.0.1",
]
[[package]]
@@ -2543,9 +2634,9 @@ dependencies = [
[[package]]
name = "image-webp"
-version = "0.2.2"
+version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "14d75c7014ddab93c232bc6bb9f64790d3dfd1d605199acd4b40b6d69e691e9f"
+checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b"
dependencies = [
"byteorder-lite",
"quick-error",
@@ -2604,7 +2695,26 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
+]
+
+[[package]]
+name = "intl-memoizer"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
+dependencies = [
+ "type-map",
+ "unic-langid",
+]
+
+[[package]]
+name = "intl_pluralrules"
+version = "7.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
+dependencies = [
+ "unic-langid",
]
[[package]]
@@ -2744,9 +2854,9 @@ dependencies = [
[[package]]
name = "jpeg-decoder"
-version = "0.3.1"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
+checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]]
name = "js-sys"
@@ -2804,9 +2914,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]]
name = "libc"
-version = "0.2.173"
+version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb"
+checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libfuzzer-sys"
@@ -2925,9 +3035,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
-version = "0.11.4"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c592ad9fbc1b7838633b3ae55ce69b17d01150c72fcef229fbb819d39ee51ee"
+checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
[[package]]
name = "malloc_buf"
@@ -2958,6 +3068,12 @@ dependencies = [
]
[[package]]
+name = "md5"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+
+[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3293,19 +3409,25 @@ dependencies = [
"egui-winit",
"ehttp",
"enostr",
+ "fluent",
+ "fluent-langneg",
+ "fluent-resmgr",
"hashbrown",
"hex",
"image",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lightning-invoice",
+ "md5",
"mime_guess",
"nostr 0.37.0",
"nostrdb",
"nwc",
+ "once_cell",
"poll-promise",
"profiling",
"puffin",
"puffin_egui",
+ "regex",
"secp256k1 0.30.0",
"serde",
"serde_json",
@@ -3317,6 +3439,7 @@ dependencies = [
"tokenator",
"tokio",
"tracing",
+ "unic-langid",
"url",
"uuid",
]
@@ -3494,7 +3617,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -3528,23 +3651,24 @@ dependencies = [
[[package]]
name = "num_enum"
-version = "0.7.3"
+version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a"
dependencies = [
"num_enum_derive",
+ "rustversion",
]
[[package]]
name = "num_enum_derive"
-version = "0.7.3"
+version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -4031,7 +4155,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"unicase",
]
@@ -4052,6 +4176,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
+name = "pin-cell"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1f4c4ebd3c5f82080164b7d9cc8e505cd9536fda8c750b779daceb4b7180a7b"
+
+[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4068,7 +4198,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -4192,12 +4322,12 @@ dependencies = [
[[package]]
name = "prettyplease"
-version = "0.2.34"
+version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55"
+checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
dependencies = [
"proc-macro2",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -4210,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"
@@ -4220,9 +4356,9 @@ dependencies = [
[[package]]
name = "profiling"
-version = "1.0.16"
+version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
dependencies = [
"profiling-procmacros",
"puffin",
@@ -4230,12 +4366,12 @@ dependencies = [
[[package]]
name = "profiling-procmacros"
-version = "1.0.16"
+version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30"
+checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -4338,9 +4474,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
-version = "0.5.12"
+version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
+checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
dependencies = [
"cfg_aliases",
"libc",
@@ -4361,9 +4497,9 @@ dependencies = [
[[package]]
name = "r-efi"
-version = "5.2.0"
+version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
@@ -4461,9 +4597,9 @@ dependencies = [
[[package]]
name = "ravif"
-version = "0.11.12"
+version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6"
+checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
dependencies = [
"avif-serialize",
"imgref",
@@ -4679,7 +4815,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
- "webpki-roots 1.0.0",
+ "webpki-roots 1.0.1",
]
[[package]]
@@ -4841,9 +4977,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.27"
+version = "0.23.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
+checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
dependencies = [
"log",
"once_cell",
@@ -5042,6 +5178,12 @@ dependencies = [
]
[[package]]
+name = "self_cell"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
+
+[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5064,7 +5206,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5088,7 +5230,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5325,7 +5467,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5357,9 +5499,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.103"
+version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8"
+checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
@@ -5383,7 +5525,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5467,7 +5609,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5478,7 +5620,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5614,7 +5756,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5784,13 +5926,13 @@ dependencies = [
[[package]]
name = "tracing-attributes"
-version = "0.1.29"
+version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662"
+checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -5922,6 +6064,49 @@ dependencies = [
]
[[package]]
+name = "unic-langid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
+dependencies = [
+ "unic-langid-impl",
+ "unic-langid-macros",
+]
+
+[[package]]
+name = "unic-langid-impl"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
+dependencies = [
+ "tinystr",
+]
+
+[[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"
@@ -5935,9 +6120,9 @@ checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-normalization"
-version = "0.1.22"
+version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
dependencies = [
"tinyvec",
]
@@ -6168,7 +6353,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"wasm-bindgen-shared",
]
@@ -6203,7 +6388,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -6391,14 +6576,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
- "webpki-roots 1.0.0",
+ "webpki-roots 1.0.1",
]
[[package]]
name = "webpki-roots"
-version = "1.0.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
+checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
dependencies = [
"rustls-pki-types",
]
@@ -6620,7 +6805,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -6631,7 +6816,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -6642,7 +6827,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -6653,7 +6838,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -6736,6 +6921,15 @@ dependencies = [
]
[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.2",
+]
+
+[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7137,9 +7331,9 @@ checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]]
name = "xcursor"
-version = "0.3.8"
+version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61"
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xkbcommon-dl"
@@ -7198,7 +7392,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"synstructure",
]
@@ -7244,7 +7438,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"zbus_names",
"zvariant",
"zvariant_utils",
@@ -7264,22 +7458,22 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.25"
+version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
+checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.25"
+version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
+checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -7299,7 +7493,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"synstructure",
]
@@ -7339,7 +7533,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
]
[[package]]
@@ -7359,9 +7553,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
-version = "0.4.17"
+version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f6fe2e33d02a98ee64423802e16df3de99c43e5cf5ff983767e1128b394c8ac"
+checksum = "7384255a918371b5af158218d131530f694de9ad3815ebdd0453a940485cb0fa"
dependencies = [
"zune-core",
]
@@ -7390,7 +7584,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
- "syn 2.0.103",
+ "syn 2.0.104",
"zvariant_utils",
]
@@ -7404,6 +7598,6 @@ dependencies = [
"quote",
"serde",
"static_assertions",
- "syn 2.0.103",
+ "syn 2.0.104",
"winnow",
]
diff --git a/Cargo.toml b/Cargo.toml
@@ -30,10 +30,14 @@ egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b679
ehttp = "0.5.0"
enostr = { path = "crates/enostr" }
ewebsock = { version = "0.2.0", features = ["tls"] }
+fluent = "0.17.0"
+fluent-resmgr = "0.0.8"
+fluent-langneg = "0.13"
hex = "0.4.3"
image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0"
log = "0.4.17"
+md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] }
nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] }
@@ -45,6 +49,7 @@ notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_ui = { path = "crates/notedeck_ui" }
tokenator = { path = "crates/tokenator" }
+once_cell = "1.19.0"
open = "5.3.0"
poll-promise = { version = "0.3.0", features = ["tokio"] }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
@@ -60,6 +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 = { version = "0.9.6", features = ["macros"] }
url = "2.5.2"
urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] }
diff --git a/README.md b/README.md
@@ -111,6 +111,8 @@ Building on notedeck dev documentation is also on the roadmap.
## 🤝 Contributing
+### Developers
+
Contributions are welcome! Please check the developer documentation and follow these guidelines:
1. Fork the repository
@@ -119,6 +121,15 @@ Contributions are welcome! Please check the developer documentation and follow t
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
+### Translators
+
+Help us bring Notedeck to non-English speakers!
+
+Request to join the Notedeck translations team through [Crowdin](https://crowdin.com/project/notedeck).
+
+If you do not have a Crowdin account, sign up for one.
+If you do not see your language, please request it in Crowdin.
+
## 🔒 Security
For security issues, please refer to our [Security Policy](./SECURITY.md).
diff --git a/assets/translations/de/main.ftl b/assets/translations/de/main.ftl
@@ -0,0 +1,432 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = Über
+# Display name for account management
+Accounts_e233 = Konten
+# Column title for account management
+Accounts_f018 = Konten
+# Button label to add a relay
+Add_269d = Hinzufügen
+# Label for add column button
+Add_47df = Hinzufügen
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Eine andere Wallet hinzufügen, die nur für dieses Konto verwendet wird
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = Wallet hinzufügen um fortzufahren
+# Button label to add a new account
+Add_account_1cfc = Konto hinzufügen
+# Column title for adding new account
+Add_Account_d06c = Konto hinzufügen
+# Display name for adding account
+Add_Account_d715 = Konto hinzufügen
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = Algorithmus-Spalte hinzufügen
+# Display name for adding column
+Add_Column_c6ff = Spalte hinzufügen
+# Column title for adding new column
+Add_Column_c764 = Spalte hinzufügen
+# Display name for adding deck
+Add_Deck_6e5f = Deck hinzufügen
+# Column title for adding new deck
+Add_Deck_fabf = Deck hinzufügen
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = Externe Benachrichtigungsspalte hinzufügen
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = Hashtag-Spalte hinzufügen
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = Letzte Notizen-Spalte hinzufügen
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = Benachrichtigungs-Spalte hinzufügen
+# Button label to add a relay
+Add_relay_269d = Relay hinzufügen
+# Button label to add a wallet
+Add_Wallet_d1be = Wallet hinzufügen
+# Title for algorithmic feeds column
+Algo_2452 = Algorithmus
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = Algorithmische Feeds zur Hilfe bei der Entdeckung von Notizen
+# Label for zap amount input field
+Amount_70f0 = Menge
+# Button to send message to Dave AI assistant
+Ask_b7f4 = Fragen
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = Frage Dave etwas...
+# Profile banner URL field label
+Banner_52ef = Banner
+# Beta version label
+BETA_8e5d = BETA
+# Broadcast the note to all connected relays
+Broadcast_fe43 = Senden
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = Lokal senden
+# Button label to cancel an action
+Cancel_ed3b = Abbrechen
+# Hover text for editable zap amount
+Click_to_edit_0414 = Zum Bearbeiten anklicken
+# Display name for note composition
+Compose_Note_ad11 = Notiz erstellen
+# Column title for note composition
+Compose_Note_c094 = Notiz erstellen
+# Button label to confirm an action
+Confirm_f8a6 = Bestätigen
+# Status label for connected relay
+Connected_f8cc = Verbunden
+# Status label for connecting relay
+Connecting_6b7e = Verbinde...
+# Title for contact list column
+Contact_List_f85a = Kontaktliste
+# Column title for contact lists
+Contacts_7533 = Kontakte
+# Timeline kind label for contact lists
+Contacts_8b98 = Kontakte
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = Kontakte (letzte Notizen)
+# Button label to copy logs
+Copy_a688 = Kopieren
+# Button to copy media link to clipboard
+Copy_Link_dc7c = Link kopieren
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = Notiz-ID kopieren
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = Notiz-JSON kopieren
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = Pubkey kopieren
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = Text kopieren
+# Relative time in days
+count_d_b9be = { $count }T.
+# Relative time in hours
+count_h_3ecb = { $count }Std.
+# Relative time in minutes
+count_m_b41e = { $count }Min.
+# Relative time in months
+count_mo_7aba = { $count }Mon.
+# Relative time in seconds
+count_s_aa26 = { $count }Sek.
+# Relative time in weeks
+count_w_7468 = { $count }Wo.
+# Relative time in years
+count_y_9408 = { $count }J.
+# Button to create a new account
+Create_Account_6994 = Konto erstellen
+# Button label to create a new deck
+Create_Deck_16b7 = Deck erstellen
+# Column title for custom timelines
+Custom_a69e = Benutzerdefiniert
+# Display name for custom timelines
+Custom_cb4f = Benutzerdefiniert
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = Zap-Betrag anpassen
+# Display name for zap customization
+Customize_Zap_Amount_ed29 = Zap-Betrag anpassen
+# Column title for support page
+Damus_Support_27c0 = Damus Support
+# Label for deck name input field
+Deck_name_cd32 = Deck-Name
+# Label for decks section in side panel
+DECKS_1fad = DECKS
+# Label for default zap amount input
+Default_amount_per_zap_399d = Standardbetrag pro Zap:
+# Name of the default deck feed
+Default_Deck_fcca = Standard-Deck
+# Button label to delete a deck
+Delete_Deck_bb29 = Deck löschen
+# Tooltip for deleting a column
+Delete_this_column_8d5a = Diese Spalte löschen
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = Wallet löschen
+# Profile display name field label
+Display_name_f9d9 = Anzeigename
+# Domain identification message
+domain___will_be_used_for_identification_b67e = "{ $domain }" wird zur Identifikation verwendet
+# Column title for editing deck
+Edit_Deck_4018 = Deck bearbeiten
+# Display name for editing deck
+Edit_Deck_c9ba = Deck bearbeiten
+# Button label to edit a deck
+Edit_Deck_fd93 = Deck bearbeiten
+# Button label to edit user profile
+Edit_Profile_49e6 = Profil bearbeiten
+# Display name for profile editing
+Edit_Profile_6699 = Profil bearbeiten
+# Column title for profile editing
+Edit_Profile_8ad4 = Profil bearbeiten
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Gewünschte Hashtags hier eingeben (für mehrere, durch Leerzeichen trennen)
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = Relay hier eingeben
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = Hier den Benutzerschlüssel (npub, hex, nip05) eingeben...
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = Gib deinen Schlüssel ein
+# Instructions for entering Nostr credentials
+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_48e9 =
+ Gib deinen öffentlichen Schlüssel (npub), eine Nostr-Adresse (z. B. {$address}) oder deinen privaten Schlüssel (nsec) ein.
+ Für das Veröffentlichen von Beiträgen und andere Aktionen ist dein privater Schlüssel erforderlich.
+# Label for find user button
+Find_User_bd12 = Profil finden
+# Timeline kind label for hashtag feeds
+Hashtag_a0ab = Hashtag
+# Display name for hashtag feeds
+Hashtags_617e = Hashtags
+# Title for hashtags column
+Hashtags_f8e0 = Hashtags
+# Display name for home feed
+Home_3efc = Startseite
+# Title for Home column
+Home_8c19 = Startseite
+# Label for deck icon selection
+Icon_b0ab = Symbol
+# Title for individual user column
+Individual_b776 = Individuell
+# Error message for invalid zap amount
+Invalid_amount_6630 = Ungültiger Betrag
+# Error message for invalid key input
+Invalid_key_4726 = Ungültiger Schlüssel
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = Ungültige NWC URI
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = 100K
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = 10K
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = 20K
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = 50K
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = 5K
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = Behalte den Überblick über deine Notizen & Antworten
+# Title for last note per user column
+Last_Note_per_User_17ad = Letzte Notiz pro Profil
+# Timeline kind label for last notes per pubkey
+Last_Notes_aefe = Letzte Notizen
+# Display name for last notes per contact
+Last_Per_Pubkey__Contact_33ce = Zuletzt pro Pubkey (Kontakt)
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = Lightning-Netzwerkadresse (lud16)
+# Login page title
+Login_9eef = Anmelden
+# Login button text
+Login_now___let_s_do_this_5630 = Jetzt anmelden — auf geht's!
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = Medien von einem Profil, dem du nicht folgst
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = Verschiebt diese Spalte an eine andere Position
+# Title for the user's deck
+My_Deck_4ac5 = Mein Deck
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = Neu bei Nostr?
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = Nostr-Adresse (NIP-05-Identität)
+# Default username when profile is not available
+nostrich_df29 = Nostrich
+# Status label for disconnected relay
+Not_Connected_6292 = Nicht verbunden
+# Link text for note references
+note_cad6 = Notiz
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck ist ein Beta-Produkt. Erwarte Fehler und kontaktiere uns, wenn Probleme oder Fehler auftreten.
+# Filter label for notes only view
+Notes_03fb = Notizen
+# Label for notes-only filter
+Notes_60d2 = Notizen
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = Notizen & Antworten
+# Label for notes and replies filter
+Notes___Replies_6e3b = Notizen & Antworten
+# Timeline kind label for notifications
+Notifications_6228 = Benachrichtigungen
+# Display name for notifications
+Notifications_8029 = Benachrichtigungen
+# Column title for notifications
+Notifications_d673 = Benachrichtigungen
+# Title for notifications column
+Notifications_ef56 = Benachrichtigungen
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = Jetzt
+# Button label to open email client
+Open_Email_25e9 = E-Mail öffnen
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Öffne deinen Standard-E-Mail-Client, um Hilfe vom Damus-Team zu erhalten
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = Füge hier deine NWC-URI ein...
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = Bitte erstelle einen Namen für das Deck.
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = Bitte erstelle einen Namen für das Deck und wähle ein Symbol aus.
+# Error message for missing deck icon
+Please_select_an_icon_655b = Bitte wählen ein Symbol aus.
+# Button label to post a note
+Post_now_8a49 = Jetzt veröffentlichen
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Drücke die Schaltfläche unten, um deine neuesten Protokolle in die Zwischenablage deines Systems zu kopieren. Dann füge sie in deine E-Mail ein.
+# Display name for user profiles
+Profile_2478 = Profil
+# Timeline kind label for user profiles
+Profile_9027 = Profil
+# Profile picture URL field label
+Profile_picture_81ff = Profilbild
+# Column title for quote composition
+Quote_475c = Zitat
+# Display name for quote composition
+Quote_a38e = Zitat
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = Zitat von unbekannter Notiz
+# Label for read-only profile mode
+Read_only_82ff = Nur Lesezugriff
+# Display name for relay management
+Relays_7335 = Relays
+# Column title for relay management
+Relays_9d89 = Relays
+# Label for relay list section
+Relays_ad5e = Relays
+# Column title for reply composition
+Reply_3bf1 = Antwort
+# Display name for reply composition
+Reply_b40f = Antworten
+# Hover text for reply button
+Reply_to_this_note_f5de = Auf diese Notiz antworten
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = Antwort auf unbekannte Notiz
+# Fallback template for replying to user
+replying_to__user_15ab = Antwort an { $user }
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = Antwort an { $user } im Beitrag von jemandem
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = Antwort auf { $user }'s { $note } in { $thread_user }'s { $thread }
+# Template for replying to user's note
+replying_to__user__s__note_ccba = Antwort auf { $user }'s { $note }
+# Template for replying to root thread
+replying_to__user__s__thread_444d = Antwort auf { $user }'s { $thread }
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = Antwort auf eine Notiz
+# Hover text for repost button
+Repost_this_note_8e56 = Diese Notiz teilen
+# Label for reposted notes
+Reposted_61c8 = Teilen
+# Heading for support section
+Running_into_a_bug_1796 = Ein Fehler aufgetreten?
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = SATS
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = Sats
+# Button to save default zap amount
+Save_6f7c = Speichern
+# Button label to save profile changes
+Save_changes_00db = Änderungen speichern
+# Display name for search results
+Search_0aa0 = Suche
+# Display name for search page
+Search_4503 = Suche
+# Timeline kind label for search results
+Search_a0b8 = Suche
+# Column title for search page
+Search_c573 = Suche
+# Placeholder for search notes input field
+Search_notes_42a6 = Notizen suchen...
+# Search in progress message
+Searching_for___query_5d18 = Suche nach '{ $query }'
+# Description for Home column
+See_notes_from_your_contacts_ac16 = Notizen von deinen Kontakten ansehen
+# Description for universe column
+See_the_whole_nostr_universe_7694 = Sieh dir das ganze Nostr-Universum an
+# Button label to send a zap
+Send_1ea4 = Senden
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = Zeige die letzte Notiz für jedes Profil aus einer Liste
+# Button label to sign out of account
+Sign_out_337b = Abmelden
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = Notizen anderer Profile
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = Mitteilungen anderer Profile
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = Die letzte Notiz für jedes Profil aus deiner Kontaktliste anzeigen
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = Mit einem bestimmten Hashtag auf dem Laufenden bleiben
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = Bleibe auf dem Laufenden mit Benachrichtigungen und Erwähnungen
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = Bleib auf dem Laufenden bei den Notizen & Antworten anderer
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Bleib bei den Benachrichtigungen und Erwähnungen anderer auf dem Laufenden
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = Bleib bei den Notizen & Antworten eines anderen auf dem Laufenden
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = Bleib bei deinen Benachrichtigungen und Erwähnungen auf dem Laufenden
+# Step 1 label in support instructions
+Step_1_8656 = Schritt 1
+# Step 2 label in support instructions
+Step_2_d08d = Schritt 2
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = Abonniere die Notizen eines anderen
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = Abonniere die Notizen von jemandem
+# Display name for support page
+Support_a4b4 = Support
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = Zum Dunkelmodus wechseln
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = Zum Hellmodus wechseln
+# Button text to load blurred media
+Tap_to_Load_4b05 = Zum Laden antippen
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Die Testphase des Dave Nostr KI-Assistenten ist beendet :(. Vielen Dank fürs Ausprobieren! Zap-fähiger Dave kommt bald!
+# Column title for note thread view
+Thread_0f20 = Unterhaltungen
+# Display name for thread view
+Thread_9957 = Unterhaltungen
+# Link text for thread references
+thread_ad1f = Unterhaltungen
+# Generic timeline kind label
+Timeline_b0fc = Timeline
+# Timeline kind label for universe feed
+Universe_0a3e = Weltraum
+# Display name for universe feed
+Universe_d47e = Weltraum
+# Title for universe column
+Universe_e01e = Weltraum
+# Column title for universe feed
+Universe_ffaa = Weltraum
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = Diese Wallet nur für das aktuelle Konto verwenden
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" bei "{ $domain }" wird für die Identifikation verwendet werden
+# Profile username field label
+Username_daa7 = Benutzername
+# Column title for wallet management
+Wallet_5e50 = Wallet
+# Display name for wallet management
+Wallet_cdca = Wallet
+# Hint for deck name input field
+We_recommend_short_names_083e = Wir empfehlen kurze Namen
+# Profile website field label
+Website_7980 = Website
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = Schreib hier eine richtig coole Notiz...
+# Placeholder text for key input field
+Your_key_here_81bd = Dein Schlüssel hier...
+# Title for your notes column
+Your_Notes_f6db = Deine Notizen
+# Title for your notifications column
+Your_Notifications_080d = Deine Benachrichtigungen
+# Heading for zap (tip) action
+Zap_16b4 = Zap
+# Hover text for zap button
+Zap_this_note_42b2 = Zappe diese Notiz
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ { $count ->
+ [one] { $count } Ergebnis für '{ $query } gefunden'
+ *[other] { $count } Ergebnisse für '{ $query } gefunden'
+ }
diff --git a/assets/translations/en-US/main.ftl b/assets/translations/en-US/main.ftl
@@ -0,0 +1,542 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = About
+
+# Column title for account management
+Accounts_f018 = Accounts
+
+# Button label to add a relay
+Add_269d = Add
+
+# Label for add column button
+Add_47df = Add
+
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Add a different wallet that will only be used for this account
+
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = Add a wallet to continue
+
+# Button label to add a new account
+Add_account_1cfc = Add account
+
+# Column title for adding new account
+Add_Account_d06c = Add Account
+
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = Add Algo Column
+
+# Column title for adding new column
+Add_Column_c764 = Add Column
+
+# Column title for adding new deck
+Add_Deck_fabf = Add Deck
+
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = Add External Notifications Column
+
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = Add Hashtag Column
+
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = Add Last Notes Column
+
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = Add Notifications Column
+
+# Button label to add a relay
+Add_relay_269d = Add relay
+
+# Button label to add a wallet
+Add_Wallet_d1be = Add Wallet
+
+# Title for algorithmic feeds column
+Algo_2452 = Algo
+
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = Algorithmic feeds to aid in note discovery
+
+# Label for zap amount input field
+Amount_70f0 = Amount
+
+# Button to send message to Dave AI assistant
+Ask_b7f4 = Ask
+
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = Ask dave anything...
+
+# Profile banner URL field label
+Banner_52ef = Banner
+
+# Beta version label
+BETA_8e5d = BETA
+
+# Broadcast the note to all connected relays
+Broadcast_fe43 = Broadcast
+
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = Broadcast Local
+
+# Button label to cancel an action
+Cancel_ed3b = Cancel
+
+# Hover text for editable zap amount
+Click_to_edit_0414 = Click to edit
+
+# Column title for note composition
+Compose_Note_c094 = Compose Note
+
+# Button label to confirm an action
+Confirm_f8a6 = Confirm
+
+# Status label for connected relay
+Connected_f8cc = Connected
+
+# Status label for connecting relay
+Connecting_6b7e = Connecting...
+
+# Title for contact list column
+Contact_List_f85a = Contact List
+
+# Column title for contact lists
+Contacts_7533 = Contacts
+
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = Contacts (last notes)
+
+# Button label to copy logs
+Copy_a688 = Copy
+
+# Button to copy media link to clipboard
+Copy_Link_dc7c = Copy Link
+
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = Copy Note ID
+
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = Copy Note JSON
+
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = Copy Pubkey
+
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = Copy Text
+
+# Relative time in days
+count_d_b9be = {$count}d
+
+# Relative time in hours
+count_h_3ecb = {$count}h
+
+# Relative time in minutes
+count_m_b41e = {$count}m
+
+# Relative time in months
+count_mo_7aba = {$count}mo
+
+# Relative time in seconds
+count_s_aa26 = {$count}s
+
+# Relative time in weeks
+count_w_7468 = {$count}w
+
+# Relative time in years
+count_y_9408 = {$count}y
+
+# Button to create a new account
+Create_Account_6994 = Create Account
+
+# Button label to create a new deck
+Create_Deck_16b7 = Create Deck
+
+# Column title for custom timelines
+Custom_a69e = Custom
+
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = Customize Zap Amount
+
+# Column title for support page
+Damus_Support_27c0 = Damus Support
+
+# Label for deck name input field
+Deck_name_cd32 = Deck name
+
+# Label for decks section in side panel
+DECKS_1fad = DECKS
+
+# Label for default zap amount input
+Default_amount_per_zap_399d = Default amount per zap:
+
+# Name of the default deck feed
+Default_Deck_fcca = Default Deck
+
+# Button label to delete a deck
+Delete_Deck_bb29 = Delete Deck
+
+# Tooltip for deleting a column
+Delete_this_column_8d5a = Delete this column
+
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = Delete Wallet
+
+# Profile display name field label
+Display_name_f9d9 = Display name
+
+# Domain identification message
+domain___will_be_used_for_identification_b67e = "{$domain}" will be used for identification
+
+# Column title for editing deck
+Edit_Deck_4018 = Edit Deck
+
+# Button label to edit a deck
+Edit_Deck_fd93 = Edit Deck
+
+# Button label to edit user profile
+Edit_Profile_49e6 = Edit Profile
+
+# Column title for profile editing
+Edit_Profile_8ad4 = Edit Profile
+
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Enter the desired hashtags here (for multiple space-separated)
+
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = Enter the relay here
+
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = Enter the user's key (npub, hex, nip05) here...
+
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = Enter your key
+
+# Instructions for entering Nostr credentials
+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_48e9 = 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.
+
+# Label for find user button
+Find_User_bd12 = Find User
+
+# Title for hashtags column
+Hashtags_f8e0 = Hashtags
+
+# Title for Home column
+Home_8c19 = Home
+
+# Label for deck icon selection
+Icon_b0ab = Icon
+
+# Title for individual user column
+Individual_b776 = Individual
+
+# Error message for invalid zap amount
+Invalid_amount_6630 = Invalid amount
+
+# Error message for invalid key input
+Invalid_key_4726 = Invalid key.
+
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = Invalid NWC URI
+
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = 100K
+
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = 10K
+
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = 20K
+
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = 50K
+
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = 5K
+
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = Keep track of your notes & replies
+
+# Title for last note per user column
+Last_Note_per_User_17ad = Last Note per User
+
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = Lightning network address (lud16)
+
+# Login page title
+Login_9eef = Login
+
+# Login button text
+Login_now___let_s_do_this_5630 = Login now — let's do this!
+
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = Media from someone you don't follow
+
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = Moves this column to another position
+
+# Title for the user's deck
+My_Deck_4ac5 = My Deck
+
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = New to Nostr?
+
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = Nostr address (NIP-05 identity)
+
+# Default username when profile is not available
+nostrich_df29 = nostrich
+
+# Status label for disconnected relay
+Not_Connected_6292 = Not Connected
+
+# Link text for note references
+note_cad6 = note
+
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck is a beta product. Expect bugs and contact us when you run into issues.
+
+# Filter label for notes only view
+Notes_03fb = Notes
+
+# Label for notes-only filter
+Notes_60d2 = Notes
+
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = Notes & Replies
+
+# Label for notes and replies filter
+Notes___Replies_6e3b = Notes & Replies
+
+# Column title for notifications
+Notifications_d673 = Notifications
+
+# Title for notifications column
+Notifications_ef56 = Notifications
+
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = now
+
+# Button label to open email client
+Open_Email_25e9 = Open Email
+
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Open your default email client to get help from the Damus team
+
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = Paste your NWC URI here...
+
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = Please create a name for the deck.
+
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = Please create a name for the deck and select an icon.
+
+# Error message for missing deck icon
+Please_select_an_icon_655b = Please select an icon.
+
+# Button label to post a note
+Post_now_8a49 = Post now
+
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.
+
+# Profile picture URL field label
+Profile_picture_81ff = Profile picture
+
+# Column title for quote composition
+Quote_475c = Quote
+
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = Quote of unknown note
+
+# Label for read-only profile mode
+Read_only_82ff = Read only
+
+# Column title for relay management
+Relays_9d89 = Relays
+
+# Label for relay list section
+Relays_ad5e = Relays
+
+# Column title for reply composition
+Reply_3bf1 = Reply
+
+# Hover text for reply button
+Reply_to_this_note_f5de = Reply to this note
+
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = Reply to unknown note
+
+# Fallback template for replying to user
+replying_to__user_15ab = replying to {$user}
+
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = replying to {$user} in someone's thread
+
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = replying to {$user}'s {$note} in {$thread_user}'s {$thread}
+
+# Template for replying to user's note
+replying_to__user__s__note_ccba = replying to {$user}'s {$note}
+
+# Template for replying to root thread
+replying_to__user__s__thread_444d = replying to {$user}'s {$thread}
+
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = replying to a note
+
+# Hover text for repost button
+Repost_this_note_8e56 = Repost this note
+
+# Label for reposted notes
+Reposted_61c8 = Reposted
+
+# Heading for support section
+Running_into_a_bug_1796 = Running into a bug?
+
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = SATS
+
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = sats
+
+# Button to save default zap amount
+Save_6f7c = Save
+
+# Button label to save profile changes
+Save_changes_00db = Save changes
+
+# Column title for search page
+Search_c573 = Search
+
+# Placeholder for search notes input field
+Search_notes_42a6 = Search notes...
+
+# Search in progress message
+Searching_for___query_5d18 = Searching for '{$query}'
+
+# Description for Home column
+See_notes_from_your_contacts_ac16 = See notes from your contacts
+
+# Description for universe column
+See_the_whole_nostr_universe_7694 = See the whole nostr universe
+
+# Button label to send a zap
+Send_1ea4 = Send
+
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = Show the last note for each user from a list
+
+# Button label to sign out of account
+Sign_out_337b = Sign out
+
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = Someone else's Notes
+
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = Someone else's Notifications
+
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source the last note for each user in your contact list
+
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = Stay up to date with a certain hashtag
+
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = Stay up to date with notifications and mentions
+
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = Stay up to date with someone else's notes & replies
+
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Stay up to date with someone else's notifications and mentions
+
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = Stay up to date with someone's notes & replies
+
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = Stay up to date with your notifications and mentions
+
+# Step 1 label in support instructions
+Step_1_8656 = Step 1
+
+# Step 2 label in support instructions
+Step_2_d08d = Step 2
+
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = Subscribe to someone else's notes
+
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = Subscribe to someone's notes
+
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = Switch to dark mode
+
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = Switch to light mode
+
+# Button text to load blurred media
+Tap_to_Load_4b05 = Tap to Load
+
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!
+
+# Column title for note thread view
+Thread_0f20 = Thread
+
+# Link text for thread references
+thread_ad1f = thread
+
+# Title for universe column
+Universe_e01e = Universe
+
+# Column title for universe feed
+Universe_ffaa = Universe
+
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = Use this wallet for the current account only
+
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = "{$username}" at "{$domain}" will be used for identification
+
+# Profile username field label
+Username_daa7 = Username
+
+# Column title for wallet management
+Wallet_5e50 = Wallet
+
+# Hint for deck name input field
+We_recommend_short_names_083e = We recommend short names
+
+# Profile website field label
+Website_7980 = Website
+
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = Write a banger note here...
+
+# Placeholder text for key input field
+Your_key_here_81bd = Your key here...
+
+# Title for your notes column
+Your_Notes_f6db = Your Notes
+
+# Title for your notifications column
+Your_Notifications_080d = Your Notifications
+
+# Heading for zap (tip) action
+Zap_16b4 = Zap
+
+# Hover text for zap button
+Zap_this_note_42b2 = Zap this note
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ { $count ->
+ [one] Got {$count} result for '{$query}'
+ *[other] Got {$count} results for '{$query}'
+ }
diff --git a/assets/translations/en-XA/main.ftl b/assets/translations/en-XA/main.ftl
@@ -0,0 +1,542 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = {"["}Àbóút{"]"}
+
+# Column title for account management
+Accounts_f018 = {"["}Àççóúñts{"]"}
+
+# Button label to add a relay
+Add_269d = {"["}Àdd{"]"}
+
+# Label for add column button
+Add_47df = {"["}Àdd{"]"}
+
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = {"["}Àdd à dífféréñt wàllét thàt wíll óñly bé úséd fór thís àççóúñt{"]"}
+
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = {"["}Àdd à wàllét tó çóñtíñúé{"]"}
+
+# Button label to add a new account
+Add_account_1cfc = {"["}Àdd àççóúñt{"]"}
+
+# Column title for adding new account
+Add_Account_d06c = {"["}Àdd Àççóúñt{"]"}
+
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = {"["}Àdd Àlgó Çólúmñ{"]"}
+
+# Column title for adding new column
+Add_Column_c764 = {"["}Àdd Çólúmñ{"]"}
+
+# Column title for adding new deck
+Add_Deck_fabf = {"["}Àdd Déçk{"]"}
+
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = {"["}Àdd Éxtérñàl Ñótífíçàtíóñs Çólúmñ{"]"}
+
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = {"["}Àdd Hàshtàg Çólúmñ{"]"}
+
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = {"["}Àdd Làst Ñótés Çólúmñ{"]"}
+
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = {"["}Àdd Ñótífíçàtíóñs Çólúmñ{"]"}
+
+# Button label to add a relay
+Add_relay_269d = {"["}Àdd rélày{"]"}
+
+# Button label to add a wallet
+Add_Wallet_d1be = {"["}Àdd Wàllét{"]"}
+
+# Title for algorithmic feeds column
+Algo_2452 = {"["}Àlgó{"]"}
+
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = {"["}Àlgóríthmíç fééds tó àíd íñ ñóté dísçóvéry{"]"}
+
+# Label for zap amount input field
+Amount_70f0 = {"["}Àmóúñt{"]"}
+
+# Button to send message to Dave AI assistant
+Ask_b7f4 = {"["}Àsk{"]"}
+
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = {"["}Àsk dàvé àñythíñg...{"]"}
+
+# Profile banner URL field label
+Banner_52ef = {"["}Bàññér{"]"}
+
+# Beta version label
+BETA_8e5d = {"["}BÉTÀ{"]"}
+
+# Broadcast the note to all connected relays
+Broadcast_fe43 = {"["}Bróàdçàst{"]"}
+
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = {"["}Bróàdçàst Lóçàl{"]"}
+
+# Button label to cancel an action
+Cancel_ed3b = {"["}Çàñçél{"]"}
+
+# Hover text for editable zap amount
+Click_to_edit_0414 = {"["}Çlíçk tó édít{"]"}
+
+# Column title for note composition
+Compose_Note_c094 = {"["}Çómpósé Ñóté{"]"}
+
+# Button label to confirm an action
+Confirm_f8a6 = {"["}Çóñfírm{"]"}
+
+# Status label for connected relay
+Connected_f8cc = {"["}Çóññéçtéd{"]"}
+
+# Status label for connecting relay
+Connecting_6b7e = {"["}Çóññéçtíñg...{"]"}
+
+# Title for contact list column
+Contact_List_f85a = {"["}Çóñtàçt Líst{"]"}
+
+# Column title for contact lists
+Contacts_7533 = {"["}Çóñtàçts{"]"}
+
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = {"["}Çóñtàçts (làst ñótés){"]"}
+
+# Button label to copy logs
+Copy_a688 = {"["}Çópy{"]"}
+
+# Button to copy media link to clipboard
+Copy_Link_dc7c = {"["}Çópy Líñk{"]"}
+
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = {"["}Çópy Ñóté ÍD{"]"}
+
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = {"["}Çópy Ñóté JSÓÑ{"]"}
+
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = {"["}Çópy Púbkéy{"]"}
+
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = {"["}Çópy Téxt{"]"}
+
+# Relative time in days
+count_d_b9be = {"["}{$count}d{"]"}
+
+# Relative time in hours
+count_h_3ecb = {"["}{$count}h{"]"}
+
+# Relative time in minutes
+count_m_b41e = {"["}{$count}m{"]"}
+
+# Relative time in months
+count_mo_7aba = {"["}{$count}mó{"]"}
+
+# Relative time in seconds
+count_s_aa26 = {"["}{$count}s{"]"}
+
+# Relative time in weeks
+count_w_7468 = {"["}{$count}w{"]"}
+
+# Relative time in years
+count_y_9408 = {"["}{$count}y{"]"}
+
+# Button to create a new account
+Create_Account_6994 = {"["}Çréàté Àççóúñt{"]"}
+
+# Button label to create a new deck
+Create_Deck_16b7 = {"["}Çréàté Déçk{"]"}
+
+# Column title for custom timelines
+Custom_a69e = {"["}Çústóm{"]"}
+
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = {"["}Çústómízé Zàp Àmóúñt{"]"}
+
+# Column title for support page
+Damus_Support_27c0 = {"["}Dàmús Súppórt{"]"}
+
+# Label for deck name input field
+Deck_name_cd32 = {"["}Déçk ñàmé{"]"}
+
+# Label for decks section in side panel
+DECKS_1fad = {"["}DÉÇKS{"]"}
+
+# Label for default zap amount input
+Default_amount_per_zap_399d = {"["}Défàúlt àmóúñt pér zàp:{"]"}
+
+# Name of the default deck feed
+Default_Deck_fcca = {"["}Défàúlt Déçk{"]"}
+
+# Button label to delete a deck
+Delete_Deck_bb29 = {"["}Délété Déçk{"]"}
+
+# Tooltip for deleting a column
+Delete_this_column_8d5a = {"["}Délété thís çólúmñ{"]"}
+
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = {"["}Délété Wàllét{"]"}
+
+# Profile display name field label
+Display_name_f9d9 = {"["}Dísplày ñàmé{"]"}
+
+# Domain identification message
+domain___will_be_used_for_identification_b67e = {"["}"{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
+
+# Column title for editing deck
+Edit_Deck_4018 = {"["}Édít Déçk{"]"}
+
+# Button label to edit a deck
+Edit_Deck_fd93 = {"["}Édít Déçk{"]"}
+
+# Button label to edit user profile
+Edit_Profile_49e6 = {"["}Édít Prófílé{"]"}
+
+# Column title for profile editing
+Edit_Profile_8ad4 = {"["}Édít Prófílé{"]"}
+
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = {"["}Éñtér thé désíréd hàshtàgs héré (fór múltíplé spàçé-sépàràtéd){"]"}
+
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = {"["}Éñtér thé rélày héré{"]"}
+
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = {"["}Éñtér thé úsér's kéy (ñpúb, héx, ñíp05) héré...{"]"}
+
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = {"["}Éñtér yóúr kéy{"]"}
+
+# Instructions for entering Nostr credentials
+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_48e9 = {"["}Éñtér yóúr públíç kéy (ñpúb), ñóstr àddréss (é.g. {$address}), ór prívàté kéy (ñséç). Yóú múst éñtér yóúr prívàté kéy tó bé àblé tó póst, réply, étç.{"]"}
+
+# Label for find user button
+Find_User_bd12 = {"["}Fíñd Úsér{"]"}
+
+# Title for hashtags column
+Hashtags_f8e0 = {"["}Hàshtàgs{"]"}
+
+# Title for Home column
+Home_8c19 = {"["}Hómé{"]"}
+
+# Label for deck icon selection
+Icon_b0ab = {"["}Íçóñ{"]"}
+
+# Title for individual user column
+Individual_b776 = {"["}Íñdívídúàl{"]"}
+
+# Error message for invalid zap amount
+Invalid_amount_6630 = {"["}Íñvàlíd àmóúñt{"]"}
+
+# Error message for invalid key input
+Invalid_key_4726 = {"["}Íñvàlíd kéy.{"]"}
+
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = {"["}Íñvàlíd ÑWÇ ÚRÍ{"]"}
+
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = {"["}100K{"]"}
+
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = {"["}10K{"]"}
+
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = {"["}20K{"]"}
+
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = {"["}50K{"]"}
+
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = {"["}5K{"]"}
+
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = {"["}Kéép tràçk óf yóúr ñótés & réplíés{"]"}
+
+# Title for last note per user column
+Last_Note_per_User_17ad = {"["}Làst Ñóté pér Úsér{"]"}
+
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = {"["}Líghtñíñg ñétwórk àddréss (lúd16){"]"}
+
+# Login page title
+Login_9eef = {"["}Lógíñ{"]"}
+
+# Login button text
+Login_now___let_s_do_this_5630 = {"["}Lógíñ ñów — lét's dó thís!{"]"}
+
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = {"["}Médíà fróm sóméóñé yóú dóñ't fóllów{"]"}
+
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = {"["}Móvés thís çólúmñ tó àñóthér pósítíóñ{"]"}
+
+# Title for the user's deck
+My_Deck_4ac5 = {"["}My Déçk{"]"}
+
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = {"["}Ñéw tó Ñóstr?{"]"}
+
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = {"["}Ñóstr àddréss (ÑÍP-05 ídéñtíty){"]"}
+
+# Default username when profile is not available
+nostrich_df29 = {"["}ñóstríçh{"]"}
+
+# Status label for disconnected relay
+Not_Connected_6292 = {"["}Ñót Çóññéçtéd{"]"}
+
+# Link text for note references
+note_cad6 = {"["}ñóté{"]"}
+
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = {"["}Ñótédéçk ís à bétà pródúçt. Éxpéçt búgs àñd çóñtàçt ús whéñ yóú rúñ íñtó íssúés.{"]"}
+
+# Filter label for notes only view
+Notes_03fb = {"["}Ñótés{"]"}
+
+# Label for notes-only filter
+Notes_60d2 = {"["}Ñótés{"]"}
+
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = {"["}Ñótés & Réplíés{"]"}
+
+# Label for notes and replies filter
+Notes___Replies_6e3b = {"["}Ñótés & Réplíés{"]"}
+
+# Column title for notifications
+Notifications_d673 = {"["}Ñótífíçàtíóñs{"]"}
+
+# Title for notifications column
+Notifications_ef56 = {"["}Ñótífíçàtíóñs{"]"}
+
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = {"["}ñów{"]"}
+
+# Button label to open email client
+Open_Email_25e9 = {"["}Ópéñ Émàíl{"]"}
+
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = {"["}Ópéñ yóúr défàúlt émàíl çlíéñt tó gét hélp fróm thé Dàmús téàm{"]"}
+
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = {"["}Pàsté yóúr ÑWÇ ÚRÍ héré...{"]"}
+
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = {"["}Pléàsé çréàté à ñàmé fór thé déçk.{"]"}
+
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = {"["}Pléàsé çréàté à ñàmé fór thé déçk àñd séléçt àñ íçóñ.{"]"}
+
+# Error message for missing deck icon
+Please_select_an_icon_655b = {"["}Pléàsé séléçt àñ íçóñ.{"]"}
+
+# Button label to post a note
+Post_now_8a49 = {"["}Póst ñów{"]"}
+
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = {"["}Préss thé búttóñ bélów tó çópy yóúr móst réçéñt lógs tó yóúr systém's çlípbóàrd. Théñ pàsté ít íñtó yóúr émàíl.{"]"}
+
+# Profile picture URL field label
+Profile_picture_81ff = {"["}Prófílé píçtúré{"]"}
+
+# Column title for quote composition
+Quote_475c = {"["}Qúóté{"]"}
+
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = {"["}Qúóté óf úñkñówñ ñóté{"]"}
+
+# Label for read-only profile mode
+Read_only_82ff = {"["}Réàd óñly{"]"}
+
+# Column title for relay management
+Relays_9d89 = {"["}Rélàys{"]"}
+
+# Label for relay list section
+Relays_ad5e = {"["}Rélàys{"]"}
+
+# Column title for reply composition
+Reply_3bf1 = {"["}Réply{"]"}
+
+# Hover text for reply button
+Reply_to_this_note_f5de = {"["}Réply tó thís ñóté{"]"}
+
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = {"["}Réply tó úñkñówñ ñóté{"]"}
+
+# Fallback template for replying to user
+replying_to__user_15ab = {"["}réplyíñg tó {$user}{"]"}
+
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = {"["}réplyíñg tó {$user} íñ sóméóñé's thréàd{"]"}
+
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = {"["}réplyíñg tó {$user}'s {$note} íñ {$thread_user}'s {$thread}{"]"}
+
+# Template for replying to user's note
+replying_to__user__s__note_ccba = {"["}réplyíñg tó {$user}'s {$note}{"]"}
+
+# Template for replying to root thread
+replying_to__user__s__thread_444d = {"["}réplyíñg tó {$user}'s {$thread}{"]"}
+
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = {"["}réplyíñg tó à ñóté{"]"}
+
+# Hover text for repost button
+Repost_this_note_8e56 = {"["}Répóst thís ñóté{"]"}
+
+# Label for reposted notes
+Reposted_61c8 = {"["}Répóstéd{"]"}
+
+# Heading for support section
+Running_into_a_bug_1796 = {"["}Rúññíñg íñtó à búg?{"]"}
+
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = {"["}SÀTS{"]"}
+
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = {"["}sàts{"]"}
+
+# Button to save default zap amount
+Save_6f7c = {"["}Sàvé{"]"}
+
+# Button label to save profile changes
+Save_changes_00db = {"["}Sàvé çhàñgés{"]"}
+
+# Column title for search page
+Search_c573 = {"["}Séàrçh{"]"}
+
+# Placeholder for search notes input field
+Search_notes_42a6 = {"["}Séàrçh ñótés...{"]"}
+
+# Search in progress message
+Searching_for___query_5d18 = {"["}Séàrçhíñg fór '{$query}'{"]"}
+
+# Description for Home column
+See_notes_from_your_contacts_ac16 = {"["}Séé ñótés fróm yóúr çóñtàçts{"]"}
+
+# Description for universe column
+See_the_whole_nostr_universe_7694 = {"["}Séé thé whólé ñóstr úñívérsé{"]"}
+
+# Button label to send a zap
+Send_1ea4 = {"["}Séñd{"]"}
+
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = {"["}Shów thé làst ñóté fór éàçh úsér fróm à líst{"]"}
+
+# Button label to sign out of account
+Sign_out_337b = {"["}Sígñ óút{"]"}
+
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = {"["}Sóméóñé élsé's Ñótés{"]"}
+
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = {"["}Sóméóñé élsé's Ñótífíçàtíóñs{"]"}
+
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = {"["}Sóúrçé thé làst ñóté fór éàçh úsér íñ yóúr çóñtàçt líst{"]"}
+
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = {"["}Stày úp tó dàté wíth à çértàíñ hàshtàg{"]"}
+
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = {"["}Stày úp tó dàté wíth ñótífíçàtíóñs àñd méñtíóñs{"]"}
+
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = {"["}Stày úp tó dàté wíth sóméóñé élsé's ñótés & réplíés{"]"}
+
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = {"["}Stày úp tó dàté wíth sóméóñé élsé's ñótífíçàtíóñs àñd méñtíóñs{"]"}
+
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = {"["}Stày úp tó dàté wíth sóméóñé's ñótés & réplíés{"]"}
+
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = {"["}Stày úp tó dàté wíth yóúr ñótífíçàtíóñs àñd méñtíóñs{"]"}
+
+# Step 1 label in support instructions
+Step_1_8656 = {"["}Stép 1{"]"}
+
+# Step 2 label in support instructions
+Step_2_d08d = {"["}Stép 2{"]"}
+
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = {"["}Súbsçríbé tó sóméóñé élsé's ñótés{"]"}
+
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = {"["}Súbsçríbé tó sóméóñé's ñótés{"]"}
+
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = {"["}Swítçh tó dàrk módé{"]"}
+
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = {"["}Swítçh tó líght módé{"]"}
+
+# Button text to load blurred media
+Tap_to_Load_4b05 = {"["}Tàp tó Lóàd{"]"}
+
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = {"["}Thé Dàvé Ñóstr ÀÍ àssístàñt tríàl hàs éñdéd :(. Thàñks fór téstíñg! Zàp-éñàbléd Dàvé çómíñg sóóñ!{"]"}
+
+# Column title for note thread view
+Thread_0f20 = {"["}Thréàd{"]"}
+
+# Link text for thread references
+thread_ad1f = {"["}thréàd{"]"}
+
+# Title for universe column
+Universe_e01e = {"["}Úñívérsé{"]"}
+
+# Column title for universe feed
+Universe_ffaa = {"["}Úñívérsé{"]"}
+
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = {"["}Úsé thís wàllét fór thé çúrréñt àççóúñt óñly{"]"}
+
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = {"["}"{$username}" àt "{$domain}" wíll bé úséd fór ídéñtífíçàtíóñ{"]"}
+
+# Profile username field label
+Username_daa7 = {"["}Úsérñàmé{"]"}
+
+# Column title for wallet management
+Wallet_5e50 = {"["}Wàllét{"]"}
+
+# Hint for deck name input field
+We_recommend_short_names_083e = {"["}Wé réçómméñd shórt ñàmés{"]"}
+
+# Profile website field label
+Website_7980 = {"["}Wébsíté{"]"}
+
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = {"["}Wríté à bàñgér ñóté héré...{"]"}
+
+# Placeholder text for key input field
+Your_key_here_81bd = {"["}Yóúr kéy héré...{"]"}
+
+# Title for your notes column
+Your_Notes_f6db = {"["}Yóúr Ñótés{"]"}
+
+# Title for your notifications column
+Your_Notifications_080d = {"["}Yóúr Ñótífíçàtíóñs{"]"}
+
+# Heading for zap (tip) action
+Zap_16b4 = {"["}Zàp{"]"}
+
+# Hover text for zap button
+Zap_this_note_42b2 = {"["}Zàp thís ñóté{"]"}
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ { $count ->
+ [one] {"["}Gót {$count} résúlt fór '{$query}'{"]"}
+ *[other] {"["}Gót {$count} résúlts fór '{$query}'{"]"}
+ }
diff --git a/assets/translations/fr/main.ftl b/assets/translations/fr/main.ftl
@@ -0,0 +1,430 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = A propos
+# Display name for account management
+Accounts_e233 = Comptes
+# Column title for account management
+Accounts_f018 = Comptes
+# Button label to add a relay
+Add_269d = Ajouter
+# Label for add column button
+Add_47df = Ajouter
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = Ajouter un portefeuille différent qui ne sera utilisé que pour ce compte
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = Ajouter un portefeuille pour continuer
+# Button label to add a new account
+Add_account_1cfc = Ajouter un compte
+# Column title for adding new account
+Add_Account_d06c = Ajouter un compte
+# Display name for adding account
+Add_Account_d715 = Ajouter un compte
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = Ajouter une colonne Algo
+# Display name for adding column
+Add_Column_c6ff = Ajouter une colonne
+# Column title for adding new column
+Add_Column_c764 = Ajouter une colonne
+# Display name for adding deck
+Add_Deck_6e5f = Ajouter un deck
+# Column title for adding new deck
+Add_Deck_fabf = Ajouter un deck
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = Ajouter une colonne pour les notifications externes
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = Ajouter une colonne Hashtag
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = Ajouter une colonne pour les dernières notes
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = Ajouter une colonne pour les notifications
+# Button label to add a relay
+Add_relay_269d = Ajouter un relai
+# Button label to add a wallet
+Add_Wallet_d1be = Ajouter un portefeuille
+# Title for algorithmic feeds column
+Algo_2452 = Algo
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = Des fils algorithmiques pour faciliter la découverte de notes
+# Label for zap amount input field
+Amount_70f0 = Montant
+# Button to send message to Dave AI assistant
+Ask_b7f4 = Demander
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = Demandez à Dave n'importe quoi...
+# Profile banner URL field label
+Banner_52ef = Bannière
+# Beta version label
+BETA_8e5d = BETA
+# Broadcast the note to all connected relays
+Broadcast_fe43 = Diffusion
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = Diffusion locale
+# Button label to cancel an action
+Cancel_ed3b = Annuler
+# Hover text for editable zap amount
+Click_to_edit_0414 = Cliquer pour modifier
+# Display name for note composition
+Compose_Note_ad11 = Ecrire une note
+# Column title for note composition
+Compose_Note_c094 = Ecrire une note
+# Button label to confirm an action
+Confirm_f8a6 = Confirmer
+# Status label for connected relay
+Connected_f8cc = Connecté
+# Status label for connecting relay
+Connecting_6b7e = Connexion...
+# Title for contact list column
+Contact_List_f85a = Liste de contacts
+# Column title for contact lists
+Contacts_7533 = Contacts
+# Timeline kind label for contact lists
+Contacts_8b98 = Contacts
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = Contacts (dernières notes)
+# Button label to copy logs
+Copy_a688 = Copier
+# Button to copy media link to clipboard
+Copy_Link_dc7c = Copier le lien
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = Copier l'ID de la note
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = Copier le JSON de la note
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = Copier la Pubkey
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = Copier le texte
+# Relative time in days
+count_d_b9be = { $count }j
+# Relative time in hours
+count_h_3ecb = { $count }h
+# Relative time in minutes
+count_m_b41e = { $count }min
+# Relative time in months
+count_mo_7aba = { $count }m
+# Relative time in seconds
+count_s_aa26 = { $count }s
+# Relative time in weeks
+count_w_7468 = { $count }sem
+# Relative time in years
+count_y_9408 = { $count }a
+# Button to create a new account
+Create_Account_6994 = Créer un compte
+# Button label to create a new deck
+Create_Deck_16b7 = Créer un deck
+# Column title for custom timelines
+Custom_a69e = Personnaliser
+# Display name for custom timelines
+Custom_cb4f = Personnaliser
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = Personnaliser le montant du Zap
+# Display name for zap customization
+Customize_Zap_Amount_ed29 = Personnaliser le montant du Zap
+# Column title for support page
+Damus_Support_27c0 = Assistance Damus
+# Label for deck name input field
+Deck_name_cd32 = Nom du deck
+# Label for decks section in side panel
+DECKS_1fad = DECKS
+# Label for default zap amount input
+Default_amount_per_zap_399d = Montant par défaut pour un Zap :
+# Name of the default deck feed
+Default_Deck_fcca = Deck par défaut
+# Button label to delete a deck
+Delete_Deck_bb29 = Supprimer le deck
+# Tooltip for deleting a column
+Delete_this_column_8d5a = Supprimer cette colonne
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = Supprimer le portefeuille
+# Profile display name field label
+Display_name_f9d9 = Nom d'utilisateur
+# Domain identification message
+domain___will_be_used_for_identification_b67e = "{ $domain }" sera utilisé pour l'identification
+# Column title for editing deck
+Edit_Deck_4018 = Modifier le deck
+# Display name for editing deck
+Edit_Deck_c9ba = Modifier le deck
+# Button label to edit a deck
+Edit_Deck_fd93 = Modifier le deck
+# Button label to edit user profile
+Edit_Profile_49e6 = Modifier le profil
+# Display name for profile editing
+Edit_Profile_6699 = Modifier le profil
+# Column title for profile editing
+Edit_Profile_8ad4 = Modifier le profil
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = Entrez les hashtags souhaités ici (séparez-les avec un espace)
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = Entrer un relai ici
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = Entrer ici la clé de l'utilisateur (npub, hex, nip05)...
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = Entrez votre clé
+# Instructions for entering Nostr credentials
+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_48e9 = Entrez votre clé publique (npub), votre adresse nostr (par exemple { $address }), ou votre clé privée (nsec). Vous devez entrer votre clé privée pour pouvoir poster, répondre, etc.
+# Label for find user button
+Find_User_bd12 = Trouver un utilisateur
+# Timeline kind label for hashtag feeds
+Hashtag_a0ab = Hashtag
+# Display name for hashtag feeds
+Hashtags_617e = Hashtags
+# Title for hashtags column
+Hashtags_f8e0 = Hashtags
+# Display name for home feed
+Home_3efc = Accueil
+# Title for Home column
+Home_8c19 = Accueil
+# Label for deck icon selection
+Icon_b0ab = Icone
+# Title for individual user column
+Individual_b776 = Individuel
+# Error message for invalid zap amount
+Invalid_amount_6630 = Montant invalide
+# Error message for invalid key input
+Invalid_key_4726 = Clé non valide.
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = Invalide NWC URI
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = 100K
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = 10K
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = 20K
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = 50K
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = 5K
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = Gardez une trace de vos notes & réponses
+# Title for last note per user column
+Last_Note_per_User_17ad = Dernière note par utilisateur
+# Timeline kind label for last notes per pubkey
+Last_Notes_aefe = Dernières notes
+# Display name for last notes per contact
+Last_Per_Pubkey__Contact_33ce = Dernière par Pubkey (Contact)
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = Adresse réseau Lightning (lud16)
+# Login page title
+Login_9eef = Se connecter
+# Login button text
+Login_now___let_s_do_this_5630 = Se connecter maintenant - c'est parti !
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = Média d'une personne que vous ne suivez pas
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = Déplace cette colonne vers une autre position
+# Title for the user's deck
+My_Deck_4ac5 = Mon deck
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = Nouveau sur Nostr ?
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = Adresse Nostr (NIP-05 identité)
+# Default username when profile is not available
+nostrich_df29 = nostrich
+# Status label for disconnected relay
+Not_Connected_6292 = Non connecté
+# Link text for note references
+note_cad6 = note
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck est un produit en phase beta. Attendez-vous à des bugs et contactez-nous si vous rencontrez des problèmes.
+# Filter label for notes only view
+Notes_03fb = Notes
+# Label for notes-only filter
+Notes_60d2 = Notes
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = Notes & Réponses
+# Label for notes and replies filter
+Notes___Replies_6e3b = Notes & Réponses
+# Timeline kind label for notifications
+Notifications_6228 = Notifications
+# Display name for notifications
+Notifications_8029 = Notifications
+# Column title for notifications
+Notifications_d673 = Notifications
+# Title for notifications column
+Notifications_ef56 = Notifications
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = maintenant
+# Button label to open email client
+Open_Email_25e9 = Ouvrir Email
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = Ouvrez votre service d'email par défaut pour obtenir de l'aide de l'équipe Damus
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = Collez ici votre NWC URI...
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = Veuillez créer un nom pour le deck.
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = Veuillez créer un nom pour le deck et sélectionner une icône.
+# Error message for missing deck icon
+Please_select_an_icon_655b = Veuillez choisir une icône.
+# Button label to post a note
+Post_now_8a49 = Publier maintenant
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = Cliquez sur le bouton ci-dessous pour copier vos données les plus récentes dans le presse-papiers de votre système. Collez-les ensuite dans votre courrier électronique.
+# Display name for user profiles
+Profile_2478 = Profil
+# Timeline kind label for user profiles
+Profile_9027 = Profil
+# Profile picture URL field label
+Profile_picture_81ff = Photo de profil
+# Column title for quote composition
+Quote_475c = Citation
+# Display name for quote composition
+Quote_a38e = Citation
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = Citation d'une note inconnue
+# Label for read-only profile mode
+Read_only_82ff = En lecture seule
+# Display name for relay management
+Relays_7335 = Relais
+# Column title for relay management
+Relays_9d89 = Relais
+# Label for relay list section
+Relays_ad5e = Relais
+# Column title for reply composition
+Reply_3bf1 = Répondre
+# Display name for reply composition
+Reply_b40f = Répondre
+# Hover text for reply button
+Reply_to_this_note_f5de = Répondre à cette note
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = Répondre à la note inconnue
+# Fallback template for replying to user
+replying_to__user_15ab = répondre à { $user }
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = répondre à { $user } dans le fil de discussion
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = répondre à la { $note } de { $user } dans le { $thread } sur le { $thread_user }
+# Template for replying to user's note
+replying_to__user__s__note_ccba = répondre à la { $note } de { $user }
+# Template for replying to root thread
+replying_to__user__s__thread_444d = répondre dans le { $thread } de { $user }
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = répondre à une note
+# Hover text for repost button
+Repost_this_note_8e56 = Republier cette note
+# Label for reposted notes
+Reposted_61c8 = Republier
+# Heading for support section
+Running_into_a_bug_1796 = Vous rencontrez un problème ?
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = SATS
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = sats
+# Button to save default zap amount
+Save_6f7c = Enregistrer
+# Button label to save profile changes
+Save_changes_00db = Enregistrer les modifications
+# Display name for search results
+Search_0aa0 = Recherche
+# Display name for search page
+Search_4503 = Rechercher
+# Timeline kind label for search results
+Search_a0b8 = Recherche
+# Column title for search page
+Search_c573 = Rechercher
+# Placeholder for search notes input field
+Search_notes_42a6 = Rechercher des notes...
+# Search in progress message
+Searching_for___query_5d18 = Recherche par '{ $query }'
+# Description for Home column
+See_notes_from_your_contacts_ac16 = Afficher les notes de vos contacts
+# Description for universe column
+See_the_whole_nostr_universe_7694 = Voir l'ensemble de l'univers nostr
+# Button label to send a zap
+Send_1ea4 = Envoyer
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = Afficher la dernière note de chaque utilisateur à partir d'une liste
+# Button label to sign out of account
+Sign_out_337b = Se déconnecter
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = Notes de quelqu'un d'autre
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = Notifications de quelqu'un d'autre
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = Source de la dernière note pour chaque utilisateur de votre liste de contacts
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = Restez informé sur un hashtag
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = Restez informé avec les notifications et les mentions
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = Restez informé des notes et des réponses de quelqu'un d'autre
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = Restez informé des notifications et mentions de quelqu'un d'autre
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = Restez informé des notes et réponses de quelqu'un
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = Restez informé pour vos notifications et mentions
+# Step 1 label in support instructions
+Step_1_8656 = Etape 1
+# Step 2 label in support instructions
+Step_2_d08d = Etape 2
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = S'abonner aux notes de quelqu'un d'autre
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = S'abonner aux notes de quelqu'un
+# Display name for support page
+Support_a4b4 = Assistance
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = Passer en mode sombre
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = Passer en mode clair
+# Button text to load blurred media
+Tap_to_Load_4b05 = Appuyer pour charger
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = La période d'essai de l'assistant IA Dave Nostr est terminée :(. Merci de l'avoir testé ! Un Dave compatible-Zap sera bientôt disponible !
+# Column title for note thread view
+Thread_0f20 = Fil
+# Display name for thread view
+Thread_9957 = Fil
+# Link text for thread references
+thread_ad1f = fil
+# Generic timeline kind label
+Timeline_b0fc = Chronologie
+# Timeline kind label for universe feed
+Universe_0a3e = Universel
+# Display name for universe feed
+Universe_d47e = Universel
+# Title for universe column
+Universe_e01e = Universel
+# Column title for universe feed
+Universe_ffaa = Universel
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = Utiliser ce portefeuille pour le compte actuel
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" à "{ $domain }" sera utilisé pour l'identification
+# Profile username field label
+Username_daa7 = Nom d'utilisateur
+# Column title for wallet management
+Wallet_5e50 = Portefeuille
+# Display name for wallet management
+Wallet_cdca = Portefeuille
+# Hint for deck name input field
+We_recommend_short_names_083e = Nous recommandons des noms courts
+# Profile website field label
+Website_7980 = Site web
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = Écrivez une note banger ici...
+# Placeholder text for key input field
+Your_key_here_81bd = Votre clé ici...
+# Title for your notes column
+Your_Notes_f6db = Vos Notes
+# Title for your notifications column
+Your_Notifications_080d = Vos notifications
+# Heading for zap (tip) action
+Zap_16b4 = Zap
+# Hover text for zap button
+Zap_this_note_42b2 = Zap cette note
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ { $count ->
+ [one] A obtenu { $count } pour '{ $query }'
+ *[other] A obtenu { $count } pour '{ $query }'
+ }
diff --git a/assets/translations/zh-CN/main.ftl b/assets/translations/zh-CN/main.ftl
@@ -0,0 +1,431 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = 关于
+# Display name for account management
+Accounts_e233 = 帐户
+# Column title for account management
+Accounts_f018 = 帐户
+# Button label to add a relay
+Add_269d = 添加
+# Label for add column button
+Add_47df = 添加
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = 添加一个仅用于此帐户的不同钱包
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = 添加钱包以继续
+# Button label to add a new account
+Add_account_1cfc = 添加帐户
+# Column title for adding new account
+Add_Account_d06c = 添加帐户
+# Display name for adding account
+Add_Account_d715 = 添加帐户
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = 添加算法列
+# Display name for adding column
+Add_Column_c6ff = 添加列
+# Column title for adding new column
+Add_Column_c764 = 添加列
+# Display name for adding deck
+Add_Deck_6e5f = 添加仪表板
+# Column title for adding new deck
+Add_Deck_fabf = 添加仪表板
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = 添加外部通知列
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = 添加标签列
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = 添加最新笔记列
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = 添加通知列
+# Button label to add a relay
+Add_relay_269d = 添加中继器
+# Button label to add a wallet
+Add_Wallet_d1be = 添加钱包
+# Title for algorithmic feeds column
+Algo_2452 = 算法
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = 用于帮助发现笔记的算法源
+# Label for zap amount input field
+Amount_70f0 = 金额
+# Button to send message to Dave AI assistant
+Ask_b7f4 = 询问
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = 向 Dave 提问任何问题…
+# Profile banner URL field label
+Banner_52ef = 横幅
+# Beta version label
+BETA_8e5d = BETA
+# Broadcast the note to all connected relays
+Broadcast_fe43 = 广播
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = 仅广播至本地中继
+# Button label to cancel an action
+Cancel_ed3b = 取消
+# Hover text for editable zap amount
+Click_to_edit_0414 = 点击以编辑
+# Display name for note composition
+Compose_Note_ad11 = 撰写笔记
+# Column title for note composition
+Compose_Note_c094 = 撰写笔记
+# Button label to confirm an action
+Confirm_f8a6 = 确认
+# Status label for connected relay
+Connected_f8cc = 已连接
+# Status label for connecting relay
+Connecting_6b7e = 正在连接...
+# Title for contact list column
+Contact_List_f85a = 联系人列表
+# Column title for contact lists
+Contacts_7533 = 联系人
+# Timeline kind label for contact lists
+Contacts_8b98 = 联系人
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = 联系人(最新笔记)
+# Button label to copy logs
+Copy_a688 = 复制
+# Button to copy media link to clipboard
+Copy_Link_dc7c = 复制链接
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = 复制笔记 ID
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = 复制笔记 JSON
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = 复制公钥
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = 复制文本
+# Relative time in days
+count_d_b9be = { $count }天
+# Relative time in hours
+count_h_3ecb = { $count }小时
+# Relative time in minutes
+count_m_b41e = { $count }分钟
+# Relative time in months
+count_mo_7aba = { $count }月
+# Relative time in seconds
+count_s_aa26 = { $count }秒
+# Relative time in weeks
+count_w_7468 = { $count }周
+# Relative time in years
+count_y_9408 = { $count }年
+# Button to create a new account
+Create_Account_6994 = 创建帐户
+# Button label to create a new deck
+Create_Deck_16b7 = 创建仪表板
+# Column title for custom timelines
+Custom_a69e = 自定义
+# Display name for custom timelines
+Custom_cb4f = 自定义
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = 自定义打闪金额
+# Display name for zap customization
+Customize_Zap_Amount_ed29 = 自定义打闪金额
+# Column title for support page
+Damus_Support_27c0 = 达摩支持
+# Label for deck name input field
+Deck_name_cd32 = 仪表板名称
+# Label for decks section in side panel
+DECKS_1fad = 仪表板
+# Label for default zap amount input
+Default_amount_per_zap_399d = 打闪默认金额:
+# Name of the default deck feed
+Default_Deck_fcca = 默认仪表板
+# Button label to delete a deck
+Delete_Deck_bb29 = 删除仪表板
+# Tooltip for deleting a column
+Delete_this_column_8d5a = 删除此列
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = 删除钱包
+# Profile display name field label
+Display_name_f9d9 = 显示名称
+# Domain identification message
+domain___will_be_used_for_identification_b67e = "{ $domain }" 将用于身份识别
+# Column title for editing deck
+Edit_Deck_4018 = 编辑仪表板
+# Display name for editing deck
+Edit_Deck_c9ba = 编辑仪表板
+# Button label to edit a deck
+Edit_Deck_fd93 = 编辑仪表板
+# Button label to edit user profile
+Edit_Profile_49e6 = 编辑个人档案
+# Display name for profile editing
+Edit_Profile_6699 = 编辑个人档案
+# Column title for profile editing
+Edit_Profile_8ad4 = 编辑个人档案
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = 在此输入所需的标签 (用于多个时以空格分隔)
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = 在此输入中继器
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = 在此输入用户的密钥(npub、hex、nip05)...
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = 请输入你的密钥
+# Instructions for entering Nostr credentials
+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_48e9 = 请输入你的公钥(npub)、nostr 地址(如 { $address })、或私钥(nsec)。 你必须输入你的私钥才能发帖、回复等等。
+# Label for find user button
+Find_User_bd12 = 查找用户
+# Timeline kind label for hashtag feeds
+Hashtag_a0ab = 标签
+# Display name for hashtag feeds
+Hashtags_617e = 标签
+# Title for hashtags column
+Hashtags_f8e0 = 标签
+# Display name for home feed
+Home_3efc = 主页
+# Title for Home column
+Home_8c19 = 主页
+# Label for deck icon selection
+Icon_b0ab = 图标
+# Title for individual user column
+Individual_b776 = 个人
+# Error message for invalid zap amount
+Invalid_amount_6630 = 无效金额
+# Error message for invalid key input
+Invalid_key_4726 = 无效密钥。
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = 无效 NWC URI
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = 10万
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = 1万
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = 2万
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = 5万
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = 5千
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = 随时查看你的笔记和回复
+# Title for last note per user column
+Last_Note_per_User_17ad = 每个用户的最新笔记
+# Timeline kind label for last notes per pubkey
+Last_Notes_aefe = 最新笔记
+# Display name for last notes per contact
+Last_Per_Pubkey__Contact_33ce = 每个公钥(联系人)的最新笔记
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = 闪电网络地址(lud16)
+# Login page title
+Login_9eef = 登录
+# Login button text
+Login_now___let_s_do_this_5630 = 立即登录——让我们开始吧!
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = 来自你不关注的用户的媒体
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = 将此列移动到其他位置
+# Title for the user's deck
+My_Deck_4ac5 = 我的仪表板
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = 第一次使用 Nostr?
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = Nostr 地址 (NIP-05 标识符)
+# Default username when profile is not available
+nostrich_df29 = nostr 用户
+# Status label for disconnected relay
+Not_Connected_6292 = 未连接
+# Link text for note references
+note_cad6 = 笔记
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck目前是测试版产品。可能会出现故障,如果遇到问题请及时联系我们。
+# Filter label for notes only view
+Notes_03fb = 笔记
+# Label for notes-only filter
+Notes_60d2 = 笔记
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = 笔记和回复
+# Label for notes and replies filter
+Notes___Replies_6e3b = 笔记和回复
+# Timeline kind label for notifications
+Notifications_6228 = 通知
+# Display name for notifications
+Notifications_8029 = 通知
+# Column title for notifications
+Notifications_d673 = 通知
+# Title for notifications column
+Notifications_ef56 = 通知
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = 刚刚
+# Button label to open email client
+Open_Email_25e9 = 打开电子邮箱
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = 打开你的默认电子邮件客户端以获得达摩团队的帮助
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = 在此粘贴你的 NWC URI...
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = 请为仪表板创建一个名称。
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = 请为仪表板创建一个名称并选择一个图标。
+# Error message for missing deck icon
+Please_select_an_icon_655b = 请选择一个图标。
+# Button label to post a note
+Post_now_8a49 = 立即发布
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = 请按下面的按钮将你最近的日志复制到系统剪贴板,然后将其粘贴到你的电子邮件。
+# Display name for user profiles
+Profile_2478 = 个人资料
+# Timeline kind label for user profiles
+Profile_9027 = 个人资料
+# Profile picture URL field label
+Profile_picture_81ff = 头像图片
+# Column title for quote composition
+Quote_475c = 引用
+# Display name for quote composition
+Quote_a38e = 引用
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = 引用未知笔记
+# Label for read-only profile mode
+Read_only_82ff = 只读
+# Display name for relay management
+Relays_7335 = 中继器
+# Column title for relay management
+Relays_9d89 = 中继器
+# Label for relay list section
+Relays_ad5e = 中继器
+# Column title for reply composition
+Reply_3bf1 = 回复
+# Display name for reply composition
+Reply_b40f = 回复
+# Hover text for reply button
+Reply_to_this_note_f5de = 回复此笔记
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = 回复未知笔记
+# Fallback template for replying to user
+replying_to__user_15ab = 正在回复{ $user }
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = 正在回复某人帖子中的{ $user }
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = 正在回复在{ $thread_user }的{ $thread }中的{ $user }的{ $note }
+# Template for replying to user's note
+replying_to__user__s__note_ccba = 正在回复{ $user }的{ $note }
+# Template for replying to root thread
+replying_to__user__s__thread_444d = 正在回复{ $user }的{ $thread }
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = 正在回复笔记
+# Hover text for repost button
+Repost_this_note_8e56 = 转发此笔记
+# Label for reposted notes
+Reposted_61c8 = 已转发
+# Heading for support section
+Running_into_a_bug_1796 = 遇到故障了吗?
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = 聪
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = 聪
+# Button to save default zap amount
+Save_6f7c = 保存
+# Button label to save profile changes
+Save_changes_00db = 保存变更
+# Display name for search results
+Search_0aa0 = 搜索
+# Display name for search page
+Search_4503 = 搜索
+# Timeline kind label for search results
+Search_a0b8 = 搜索
+# Column title for search page
+Search_c573 = 搜索
+# Placeholder for search notes input field
+Search_notes_42a6 = 搜索笔记...
+# Search in progress message
+Searching_for___query_5d18 = 正在搜索'{ $query }'
+# Description for Home column
+See_notes_from_your_contacts_ac16 = 查看来自你的联系人的笔记
+# Description for universe column
+See_the_whole_nostr_universe_7694 = 查看整个 nostr 宇宙
+# Button label to send a zap
+Send_1ea4 = 发送
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = 显示列表中每个用户的最新一条笔记
+# Button label to sign out of account
+Sign_out_337b = 登出
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = 其他人的笔记
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = 其他人的通知
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = 获取你的联系人列表中每个用户的最新一条笔记
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = 获取某个标签的最新动态
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = 获取通知和提及的最新动态
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = 获取其他用户的笔记和回复的最新动态
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = 获取其他用户的通知和提及的最新动态
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = 获取某人的笔记和回复的最新动态
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = 获取你的通知和提及的最新动态
+# Step 1 label in support instructions
+Step_1_8656 = 第一步
+# Step 2 label in support instructions
+Step_2_d08d = 第二步
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = 订阅他人的笔记
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = 订阅某人的笔记
+# Display name for support page
+Support_a4b4 = 获取帮助
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = 切换到暗色模式
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = 切换到亮色模式
+# Button text to load blurred media
+Tap_to_Load_4b05 = 点击加载
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Dave Nostr AI 助手试用期已经结束 :(。感谢测试!可打闪付款的 Dave 即将来临!
+# Column title for note thread view
+Thread_0f20 = 帖子
+# Display name for thread view
+Thread_9957 = 帖子
+# Link text for thread references
+thread_ad1f = 帖子
+# Generic timeline kind label
+Timeline_b0fc = 时间线
+# Timeline kind label for universe feed
+Universe_0a3e = 宇宙
+# Display name for universe feed
+Universe_d47e = 宇宙
+# Title for universe column
+Universe_e01e = 宇宙
+# Column title for universe feed
+Universe_ffaa = 宇宙
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = 此钱包仅限用于当前帐户
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" 于 "{ $domain }" 将被用于身份识别
+# Profile username field label
+Username_daa7 = 用户名
+# Column title for wallet management
+Wallet_5e50 = 钱包
+# Display name for wallet management
+Wallet_cdca = 钱包
+# Hint for deck name input field
+We_recommend_short_names_083e = 我们推荐使用简短的名称
+# Profile website field label
+Website_7980 = 网站
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = 在这里写条超赞的笔记...
+# Placeholder text for key input field
+Your_key_here_81bd = 在此输入你的密钥...
+# Title for your notes column
+Your_Notes_f6db = 你的笔记
+# Title for your notifications column
+Your_Notifications_080d = 你的通知
+# Heading for zap (tip) action
+Zap_16b4 = 打闪
+# Hover text for zap button
+Zap_this_note_42b2 = 打闪此笔记
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ {
+ $count ->
+ [one] 查询"{ $query }"得到{ $count }条结果
+ *[other] 查询"{ $query }"得到{ $count }条结果
+ }
diff --git a/assets/translations/zh-TW/main.ftl b/assets/translations/zh-TW/main.ftl
@@ -0,0 +1,431 @@
+# Main translation file for Notedeck
+# This file contains common UI strings used throughout the application
+# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY
+
+
+# Regular strings
+
+# Profile about/bio field label
+About_00c0 = 關於
+# Display name for account management
+Accounts_e233 = 帳戶
+# Column title for account management
+Accounts_f018 = 帳戶
+# Button label to add a relay
+Add_269d = 添加
+# Label for add column button
+Add_47df = 添加
+# Button label to add a different wallet
+Add_a_different_wallet_that_will_only_be_used_for_this_account_de8d = 添加一個僅用於此帳戶的不同錢包
+# Error message for missing wallet
+Add_a_wallet_to_continue_d170 = 添加錢包以繼續
+# Button label to add a new account
+Add_account_1cfc = 新增帳戶
+# Column title for adding new account
+Add_Account_d06c = 新增帳戶
+# Display name for adding account
+Add_Account_d715 = 新增帳戶
+# Column title for adding algorithm column
+Add_Algo_Column_0d75 = 添加算法列
+# Display name for adding column
+Add_Column_c6ff = 添加列
+# Column title for adding new column
+Add_Column_c764 = 添加列
+# Display name for adding deck
+Add_Deck_6e5f = 添加儀表板
+# Column title for adding new deck
+Add_Deck_fabf = 添加儀表板
+# Column title for adding external notifications column
+Add_External_Notifications_Column_41ae = 添加外部通知列
+# Column title for adding hashtag column
+Add_Hashtag_Column_ebf4 = 添加標籤列
+# Column title for adding last notes column
+Add_Last_Notes_Column_bbad = 添加最新筆記列
+# Column title for adding notifications column
+Add_Notifications_Column_79f8 = 添加通知列
+# Button label to add a relay
+Add_relay_269d = 新增中繼器
+# Button label to add a wallet
+Add_Wallet_d1be = 新增錢包
+# Title for algorithmic feeds column
+Algo_2452 = 算法
+# Description for algorithmic feeds column
+Algorithmic_feeds_to_aid_in_note_discovery_d344 = 用於幫助發現筆記的算法源
+# Label for zap amount input field
+Amount_70f0 = 金額
+# Button to send message to Dave AI assistant
+Ask_b7f4 = 詢問
+# Placeholder text for Dave AI input field
+Ask_dave_anything_33d1 = 向 Dave 提問任何問題...
+# Profile banner URL field label
+Banner_52ef = 橫幅
+# Beta version label
+BETA_8e5d = 測試版
+# Broadcast the note to all connected relays
+Broadcast_fe43 = 廣播
+# Broadcast the note only to local network relays
+Broadcast_Local_7e50 = 僅廣播至本地中繼
+# Button label to cancel an action
+Cancel_ed3b = 取消
+# Hover text for editable zap amount
+Click_to_edit_0414 = 點擊編輯
+# Display name for note composition
+Compose_Note_ad11 = 撰寫筆記
+# Column title for note composition
+Compose_Note_c094 = 撰寫筆記
+# Button label to confirm an action
+Confirm_f8a6 = 確認
+# Status label for connected relay
+Connected_f8cc = 已連接
+# Status label for connecting relay
+Connecting_6b7e = 正在連接 ...
+# Title for contact list column
+Contact_List_f85a = 聯絡人列表
+# Column title for contact lists
+Contacts_7533 = 聯絡人
+# Timeline kind label for contact lists
+Contacts_8b98 = 聯絡人
+# Column title for last notes per contact
+Contacts__last_notes_3f84 = 聯絡人(最新筆記)
+# Button label to copy logs
+Copy_a688 = 複製
+# Button to copy media link to clipboard
+Copy_Link_dc7c = 複製鏈接
+# Copy the unique note identifier to clipboard
+Copy_Note_ID_6b45 = 複製筆記 ID
+# Copy the raw note data in JSON format to clipboard
+Copy_Note_JSON_9e4e = 複製筆記 JSON
+# Copy the author's public key to clipboard
+Copy_Pubkey_9cc4 = 複製公鑰
+# Copy the text content of the note to clipboard
+Copy_Text_f81c = 複製文字
+# Relative time in days
+count_d_b9be = { $count }天
+# Relative time in hours
+count_h_3ecb = { $count }小時
+# Relative time in minutes
+count_m_b41e = { $count }分鐘
+# Relative time in months
+count_mo_7aba = { $count }月
+# Relative time in seconds
+count_s_aa26 = { $count }秒
+# Relative time in weeks
+count_w_7468 = { $count }週
+# Relative time in years
+count_y_9408 = { $count }年
+# Button to create a new account
+Create_Account_6994 = 創建帳戶
+# Button label to create a new deck
+Create_Deck_16b7 = 創建儀表板
+# Column title for custom timelines
+Custom_a69e = 自訂
+# Display name for custom timelines
+Custom_cb4f = 自訂
+# Column title for zap amount customization
+Customize_Zap_Amount_cfc4 = 自訂打閃金額
+# Display name for zap customization
+Customize_Zap_Amount_ed29 = 自訂打閃金額
+# Column title for support page
+Damus_Support_27c0 = 達摩支持
+# Label for deck name input field
+Deck_name_cd32 = 儀表板名稱
+# Label for decks section in side panel
+DECKS_1fad = 儀表板
+# Label for default zap amount input
+Default_amount_per_zap_399d = 默認打閃金額:
+# Name of the default deck feed
+Default_Deck_fcca = 默認儀表板
+# Button label to delete a deck
+Delete_Deck_bb29 = 刪除儀表板
+# Tooltip for deleting a column
+Delete_this_column_8d5a = 刪除此列
+# Button label to delete a wallet
+Delete_Wallet_d1d4 = 刪除錢包
+# Profile display name field label
+Display_name_f9d9 = 顯示名稱
+# Domain identification message
+domain___will_be_used_for_identification_b67e = "{ $domain }" 將用於身份識別
+# Column title for editing deck
+Edit_Deck_4018 = 編輯儀表板
+# Display name for editing deck
+Edit_Deck_c9ba = 編輯儀表板
+# Button label to edit a deck
+Edit_Deck_fd93 = 編輯儀表板
+# Button label to edit user profile
+Edit_Profile_49e6 = 編輯個人檔案
+# Display name for profile editing
+Edit_Profile_6699 = 編輯個人檔案
+# Column title for profile editing
+Edit_Profile_8ad4 = 編輯個人檔案
+# Placeholder for hashtag input field
+Enter_the_desired_hashtags_here__for_multiple_space-separated_7a69 = 在此輸入所需的標籤(用於多個時以空格分隔)
+# Placeholder for relay input field
+Enter_the_relay_here_1c8b = 在此輸入中繼器
+# Hint text to prompt entering the user's public key.
+Enter_the_user_s_key__npub__hex__nip05__here_650c = 請輸入用戶的密鑰(npub、hex、nip05)...
+# Label for key input field. Key can be public key (npub), private key (nsec), or Nostr address (NIP-05).
+Enter_your_key_0fca = 請輸入你的密鑰
+# Instructions for entering Nostr credentials
+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_48e9 = 請輸入你的公鑰(npub)、nostr 地址(如 { $address })、或私鑰(nsec)。你必須輸入你的私鑰才能發貼、回覆等等。
+# Label for find user button
+Find_User_bd12 = 查找用戶
+# Timeline kind label for hashtag feeds
+Hashtag_a0ab = 標籤
+# Display name for hashtag feeds
+Hashtags_617e = 標籤
+# Title for hashtags column
+Hashtags_f8e0 = 標籤
+# Display name for home feed
+Home_3efc = 主頁
+# Title for Home column
+Home_8c19 = 主頁
+# Label for deck icon selection
+Icon_b0ab = 圖標
+# Title for individual user column
+Individual_b776 = 個人
+# Error message for invalid zap amount
+Invalid_amount_6630 = 無效金額
+# Error message for invalid key input
+Invalid_key_4726 = 無效密鑰。
+# Error message for invalid Nostr Wallet Connect URI
+Invalid_NWC_URI_031b = 無效 NWC URI
+# Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount.
+k_100K_686c = 10萬
+# Zap amount button for 10000 sats. Abbreviated because the button is too small to display the full amount.
+k_10K_f7e6 = 1萬
+# Zap amount button for 20000 sats. Abbreviated because the button is too small to display the full amount.
+k_20K_4977 = 2萬
+# Zap amount button for 50000 sats. Abbreviated because the button is too small to display the full amount.
+k_50K_c2dc = 5萬
+# Zap amount button for 5000 sats. Abbreviated because the button is too small to display the full amount.
+k_5K_f7e6 = 5千
+# Description for your notes column
+Keep_track_of_your_notes___replies_a334 = 隨時查看你的筆記和回覆
+# Title for last note per user column
+Last_Note_per_User_17ad = 每個用戶的最新筆記
+# Timeline kind label for last notes per pubkey
+Last_Notes_aefe = 最新筆記
+# Display name for last notes per contact
+Last_Per_Pubkey__Contact_33ce = 每個公鑰(聯繫人)的最新筆記
+# Bitcoin Lightning network address field label
+Lightning_network_address__lud16_ea51 = 閃電網絡地址(lud16)
+# Login page title
+Login_9eef = 登錄
+# Login button text
+Login_now___let_s_do_this_5630 = 立即登錄——讓我們開始吧!
+# Text shown on blurred media from unfollowed users
+Media_from_someone_you_don_t_follow_5611 = 來自你不關注的用戶的媒體
+# Tooltip for moving a column
+Moves_this_column_to_another_position_0d4b = 將此列移動到其他位置
+# Title for the user's deck
+My_Deck_4ac5 = 我的儀表板
+# Label asking if the user is new to Nostr. Underneath this label is a button to create an account.
+New_to_Nostr_a2fd = 第一次使用 Nostr?
+# NIP-05 identity field label
+Nostr_address__NIP-05_identity_74a2 = Nostr 地址(NIP-05 標識符)
+# Default username when profile is not available
+nostrich_df29 = nostr 用戶
+# Status label for disconnected relay
+Not_Connected_6292 = 未連接
+# Link text for note references
+note_cad6 = 筆記
+# Beta product warning message
+Notedeck_is_a_beta_product__Expect_bugs_and_contact_us_when_you_run_into_issues_a671 = Notedeck 目前是測試版產品。可能會出現故障,如果遇到問題請及時聯繫我們。
+# Filter label for notes only view
+Notes_03fb = 筆記
+# Label for notes-only filter
+Notes_60d2 = 筆記
+# Filter label for notes and replies view
+Notes___Replies_1ec2 = 筆記和回覆
+# Label for notes and replies filter
+Notes___Replies_6e3b = 筆記和回覆
+# Timeline kind label for notifications
+Notifications_6228 = 通知
+# Display name for notifications
+Notifications_8029 = 通知
+# Column title for notifications
+Notifications_d673 = 通知
+# Title for notifications column
+Notifications_ef56 = 通知
+# Relative time for very recent events (less than 3 seconds)
+now_2181 = 剛剛
+# Button label to open email client
+Open_Email_25e9 = 打開電子郵箱
+# Instruction to open email client
+Open_your_default_email_client_to_get_help_from_the_Damus_team_68dc = 打開你的默認電子郵件客戶端以獲得達摩團隊的幫助
+# Placeholder text for NWC URI input
+Paste_your_NWC_URI_here_b471 = 在此貼上你的 NWC URI...
+# Error message for missing deck name
+Please_create_a_name_for_the_deck_38e7 = 請為儀表板創建一個名稱。
+# Error message for missing deck name and icon
+Please_create_a_name_for_the_deck_and_select_an_icon_0add = 請為儀表板創建一個名稱並選擇一個圖標。
+# Error message for missing deck icon
+Please_select_an_icon_655b = 請選擇一個圖標。
+# Button label to post a note
+Post_now_8a49 = 立即發布
+# Instruction for copying logs
+Press_the_button_below_to_copy_your_most_recent_logs_to_your_system_s_clipboard__Then_paste_it_into_your_email_322e = 請按下面的按鈕將你最近的日誌複製到剪貼板,然後將其粘貼到你的電子郵件。
+# Display name for user profiles
+Profile_2478 = 個人檔案
+# Timeline kind label for user profiles
+Profile_9027 = 個人檔案
+# Profile picture URL field label
+Profile_picture_81ff = 頭像圖片
+# Column title for quote composition
+Quote_475c = 引用
+# Display name for quote composition
+Quote_a38e = 引用
+# Error message when quote note cannot be found
+Quote_of_unknown_note_e4f0 = 引用未知筆記
+# Label for read-only profile mode
+Read_only_82ff = 只讀
+# Display name for relay management
+Relays_7335 = 中繼器
+# Column title for relay management
+Relays_9d89 = 中繼器
+# Label for relay list section
+Relays_ad5e = 中繼器
+# Column title for reply composition
+Reply_3bf1 = 回覆
+# Display name for reply composition
+Reply_b40f = 回覆
+# Hover text for reply button
+Reply_to_this_note_f5de = 回覆此筆記
+# Error message when reply note cannot be found
+Reply_to_unknown_note_4401 = 回覆未知筆記
+# Fallback template for replying to user
+replying_to__user_15ab = 正在回覆{ $user }
+# Template for replying to user in unknown thread
+replying_to__user__in_someone_s_thread_e148 = 正在回覆某人帖子中的{ $user }
+# Template for replying to note in different user's thread
+replying_to__user__s__note__in__thread_user__s__thread_daa8 = 正在回覆在{ $thread_user }的{ $thread }中的{ $user }的{ $note }
+# Template for replying to user's note
+replying_to__user__s__note_ccba = 正在回覆{ $user }的{ $note }
+# Template for replying to root thread
+replying_to__user__s__thread_444d = 正在回覆{ $user }的{ $thread }
+# Fallback text when reply note is not found
+replying_to_a_note_e0bc = 正在回覆筆記
+# Hover text for repost button
+Repost_this_note_8e56 = 轉發此筆記
+# Label for reposted notes
+Reposted_61c8 = 已轉發
+# Heading for support section
+Running_into_a_bug_1796 = 遇到故障了嗎?
+# Label for satoshis (Bitcoin unit) for custom zap amount input field
+SATS_45d7 = 聰
+# Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings.
+sats_e5ec = 聰
+# Button to save default zap amount
+Save_6f7c = 保存
+# Button label to save profile changes
+Save_changes_00db = 保存變更
+# Display name for search results
+Search_0aa0 = 搜索
+# Display name for search page
+Search_4503 = 搜索
+# Timeline kind label for search results
+Search_a0b8 = 搜索
+# Column title for search page
+Search_c573 = 搜索
+# Placeholder for search notes input field
+Search_notes_42a6 = 搜索筆記...
+# Search in progress message
+Searching_for___query_5d18 = 正在搜索「{ $query }」
+# Description for Home column
+See_notes_from_your_contacts_ac16 = 查看來自你的聯繫人的筆記
+# Description for universe column
+See_the_whole_nostr_universe_7694 = 查看整個 nostr 宇宙
+# Button label to send a zap
+Send_1ea4 = 發送
+# Description for last note per user column
+Show_the_last_note_for_each_user_from_a_list_50e7 = 顯示列表中每個用戶的最後一條筆記
+# Button label to sign out of account
+Sign_out_337b = 登出
+# Title for someone else's notes column
+Someone_else_s_Notes_7e5f = 其他人的筆記
+# Title for someone else's notifications column
+Someone_else_s_Notifications_82e6 = 其他人的通知
+# Description for contact list column
+Source_the_last_note_for_each_user_in_your_contact_list_e157 = 獲取你的聯繫人列表中每個用戶的最新一條筆記
+# Description for hashtags column
+Stay_up_to_date_with_a_certain_hashtag_88e3 = 獲取某個標籤的最新動態
+# Description for notifications column
+Stay_up_to_date_with_notifications_and_mentions_6f4e = 獲取通知和提及的最新動態
+# Description for someone else's notes column
+Stay_up_to_date_with_someone_else_s_notes___replies_464c = 獲取其他用戶的筆記和回覆的最新動態
+# Description for someone else's notifications column
+Stay_up_to_date_with_someone_else_s_notifications_and_mentions_3473 = 獲取其他用戶的通知和提及的最新動態
+# Description for individual user column
+Stay_up_to_date_with_someone_s_notes___replies_aa78 = 獲取某人的筆記和回覆的最新動態
+# Description for your notifications column
+Stay_up_to_date_with_your_notifications_and_mentions_e73e = 獲取你的通知和提及的最新動態
+# Step 1 label in support instructions
+Step_1_8656 = 第一步
+# Step 2 label in support instructions
+Step_2_d08d = 第二步
+# Column title for subscribing to external user
+Subscribe_to_someone_else_s_notes_d1e9 = 訂閱他人的筆記
+# Column title for subscribing to individual user
+Subscribe_to_someone_s_notes_b3c8 = 訂閱某人的筆記
+# Display name for support page
+Support_a4b4 = 獲取幫助
+# Hover text for dark mode toggle button
+Switch_to_dark_mode_4dec = 切換到暗色模式
+# Hover text for light mode toggle button
+Switch_to_light_mode_72ce = 切換到亮色模式
+# Button text to load blurred media
+Tap_to_Load_4b05 = 點擊加載
+# Message shown when Dave trial period has ended
+The_Dave_Nostr_AI_assistant_trial_has_ended_____Thanks_for_testing__Zap-enabled_Dave_coming_soon_c6c7 = Dave Nostr AI 助手試用期已經結束 :(。感謝測試!可打閃付款的 Dave 即將來臨!
+# Column title for note thread view
+Thread_0f20 = 串文
+# Display name for thread view
+Thread_9957 = 串文
+# Link text for thread references
+thread_ad1f = 串文
+# Generic timeline kind label
+Timeline_b0fc = 時間線
+# Timeline kind label for universe feed
+Universe_0a3e = 宇宙
+# Display name for universe feed
+Universe_d47e = 宇宙
+# Title for universe column
+Universe_e01e = 宇宙
+# Column title for universe feed
+Universe_ffaa = 宇宙
+# Checkbox label for using wallet only for current account
+Use_this_wallet_for_the_current_account_only_61dc = 此錢包僅限用於當前帳戶
+# Username and domain identification message
+username___at___domain___will_be_used_for_identification_a4fd = "{ $username }" 於 "{ $domain }" 將被用於身份識別
+# Profile username field label
+Username_daa7 = 用戶名
+# Column title for wallet management
+Wallet_5e50 = 錢包
+# Display name for wallet management
+Wallet_cdca = 錢包
+# Hint for deck name input field
+We_recommend_short_names_083e = 我們推薦使用簡短的名稱
+# Profile website field label
+Website_7980 = 網站
+# Placeholder for note input field
+Write_a_banger_note_here_bad2 = 在這裡寫條超讚的筆記...
+# Placeholder text for key input field
+Your_key_here_81bd = 在此輸入你的密鑰...
+# Title for your notes column
+Your_Notes_f6db = 你的筆記
+# Title for your notifications column
+Your_Notifications_080d = 你的通知
+# Heading for zap (tip) action
+Zap_16b4 = 打閃
+# Hover text for zap button
+Zap_this_note_42b2 = 打閃此筆記
+
+# Pluralized strings
+
+# Search results count
+Got__count__results_for___query_85fb =
+ {
+ $count ->
+ [one] 查詢"{ $query }"得到{ $count }條結果
+ *[other] 查詢"{ $query }"得到{ $count }條結果
+ }
diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml
@@ -39,6 +39,13 @@ bech32 = { workspace = true }
lightning-invoice = { workspace = true }
secp256k1 = { workspace = true }
hashbrown = { workspace = true }
+fluent = { workspace = true }
+fluent-resmgr = { workspace = true }
+fluent-langneg = { workspace = true }
+unic-langid = { workspace = true }
+once_cell = { workspace = true }
+md5 = { workspace = true }
+regex = "1"
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/crates/notedeck/DEVELOPER.md b/crates/notedeck/DEVELOPER.md
@@ -34,6 +34,11 @@ Notedeck is built around a modular architecture that separates concerns into dis
- `ColorTheme` - Theme management
- Various UI helpers
+7. **Localization System**
+ - `LocalizationManager` - Core localization functionality
+ - `LocalizationContext` - Thread-safe context for sharing localization
+ - Fluent-based translation system
+
## Key Concepts
### Note Context and Actions
@@ -163,6 +168,197 @@ Notedeck provides several persistence mechanisms:
- `TimedSerializer` - For settings that need to be saved after a delay
- Various handlers for specific settings (zoom, theme, app size)
+### Localization System
+
+Notedeck includes a comprehensive internationalization system built on the [Fluent](https://projectfluent.org/) translation framework. The system is designed for performance and developer experience.
+
+#### Architecture
+
+The localization system consists of several key components:
+
+1. **LocalizationManager** - Core functionality for managing locales and translations
+2. **LocalizationContext** - Thread-safe context for sharing localization across the application
+3. **Fluent Resources** - Translation files in `.ftl` format stored in `assets/translations/`
+
+#### Key Features
+
+- **Efficient Caching**: Parsed Fluent resources and formatted strings are cached for performance
+- **Thread Safety**: Uses `RwLock` for safe concurrent access
+- **Dynamic Locale Switching**: Change languages at runtime without restarting
+- **Argument Support**: Localized strings can include dynamic arguments
+- **Development Tools**: Pseudolocale support for testing UI layout
+
+#### Using the tr! and tr_plural! Macros
+
+The `tr!` and `tr_plural!` macros are the primary way to use localization in Notedeck code. They provide a convenient, type-safe interface for getting localized strings.
+
+##### The tr! Macro
+
+```rust
+use notedeck::tr;
+
+// Simple string with comment
+let welcome = tr!("Welcome to Notedeck!", "Main welcome message");
+let cancel = tr!("Cancel", "Button label to cancel an action");
+
+// String with parameters
+let greeting = tr!("Hello, {name}!", "Greeting message", name="Alice");
+
+// Multiple parameters
+let message = tr!(
+ "Welcome {name} to {app}!",
+ "Welcome message with app name",
+ name="Alice",
+ app="Notedeck"
+);
+
+// In UI components
+ui.button(tr!("Reply to {user}", "Reply button text", user="alice@example.com"));
+```
+
+##### The tr_plural! Macro
+
+Use tr_plural! when there can be multiple variations of the same string depending on
+some numeric count.
+
+Not all languages follow the same pluralization rules
+
+```rust
+use notedeck::tr_plural;
+
+// Simple pluralization
+let count = 5;
+let message = tr_plural!(
+ "You have {count} note", // Singular form
+ "You have {count} notes", // Plural form
+ "Note count message", // Comment
+ count // Count value
+);
+
+// With additional parameters
+let user = "Alice";
+let message = tr_plural!(
+ "{user} has {count} note", // Singular
+ "{user} has {count} notes", // Plural
+ "User note count message", // Comment
+ count, // Count
+ user=user // Additional parameter
+);
+```
+
+##### Key Features
+
+- **Automatic Key Normalization**: Converts messages and comments into valid FTL keys
+- **Fallback Handling**: Falls back to original message if translation not found
+- **Parameter Interpolation**: Automatically handles named parameters
+- **Comment Context**: Provides context for translators
+
+##### Best Practices
+
+1. **Always Include Comments**: Comments provide context for translators
+ ```rust
+ // Good
+ tr!("Add", "Button label to add something")
+
+ // Bad
+ tr!("Add", "")
+ ```
+
+2. **Use Descriptive Comments**: Make comments specific and helpful
+ ```rust
+ // Good
+ tr!("Reply", "Button to reply to a note")
+
+ // Bad
+ tr!("Reply", "Reply")
+ ```
+
+3. **Consistent Parameter Names**: Use consistent parameter names across related strings
+ ```rust
+ // Consistent
+ tr!("Follow {user}", "Follow button", user="alice")
+ tr!("Unfollow {user}", "Unfollow button", user="alice")
+ ```
+
+4. **Always use tr_plural! for plural strings**: Not all languages follow English pluralization rules
+ ```rust
+ // Good
+ // Each language can have more (or less) than just two pluralization forms.
+ // Let the translators and the localization system help you figure that out implicitly.
+ let message = tr_plural!(
+ "You have {count} note", // Singular form
+ "You have {count} notes", // Plural form
+ "Note count message", // Comment
+ count // Count value
+ );
+
+ // Bad
+ // Not all languages follow pluralization rules of English.
+ // Some languages can have more (or less) than two variations!
+ if count == 1 {
+ tr!("You have 1 note", "Note count message")
+ } else {
+ tr!("You have {count} notes", "Note count message")
+ }
+ ```
+
+#### Translation File Format
+
+Translation files use the [Fluent](https://projectfluent.org/) format (`.ftl`).
+
+Developers should never create their own `.ftl` files. Whenever user-facing strings are changed in code, run `python3 scripts/export_source_strings.py`. This script will generate `assets/translations/en-US/main.ftl` and `assets/translations/en-XA/main.ftl`. The format of the files look like the following:
+
+```ftl
+# Simple string
+welcome_message = Welcome to Notedeck!
+
+# String with arguments
+welcome_user = Welcome {$name}!
+
+# String with pluralization
+note_count = {$count ->
+ [1] One note
+ *[other] {$count} notes
+}
+```
+
+#### Adding New Languages
+
+TODO
+
+#### Development with Pseudolocale (en-XA)
+
+For testing that all user-facing strings are going through the localization system and that the
+UI layout renders well with different language translations, enable the pseudolocale:
+
+```bash
+cargo run -- --debug --locale en-XA
+```
+
+The pseudolocale (`en-XA`) transforms English text in a way that is still readable but makes adjustments obvious enough that they are different from the original text (such as replacing English letters with accented equivalents), helping identify potential UI layout issues once it gets translated
+to other languages.
+
+Example transformations:
+- "Add relay" → "[Àdd rélày]"
+- "Cancel" → "[Çàñçél]"
+- "Confirm" → "[Çóñfírm]"
+
+#### Performance Considerations
+
+- **Resource Caching**: Parsed Fluent resources are cached per locale
+- **String Caching**: Simple strings (without arguments) are cached for repeated access
+- **Cache Management**: Caches are automatically cleared when switching locales
+- **Memory Limits**: String cache size can be limited to prevent memory growth
+
+#### Testing Localization
+
+The localization system includes comprehensive tests:
+
+```bash
+# Run localization tests
+cargo test i18n
+```
+
## Troubleshooting
### Common Issues
@@ -182,6 +378,12 @@ Notedeck provides several persistence mechanisms:
- Check for large image caches
- Consider reducing the number of active subscriptions
+4. **Localization Issues**
+ - Verify translation files exist in the correct directory structure
+ - Check that locale codes are valid (e.g., `en-US`, `es-ES`)
+ - Ensure FTL files are properly formatted
+ - Look for missing translation keys in logs
+
## Contributing
When contributing to Notedeck:
@@ -190,3 +392,5 @@ When contributing to Notedeck:
2. Add tests for new functionality
3. Update documentation as needed
4. Keep performance in mind, especially for mobile targets
+5. For UI changes, test with pseudolocale enabled
+6. When adding new strings, ensure they are properly localized
diff --git a/crates/notedeck/src/app.rs b/crates/notedeck/src/app.rs
@@ -1,4 +1,5 @@
use crate::account::FALLBACK_PUBKEY;
+use crate::i18n::Localization;
use crate::persist::{AppSizeHandler, ZoomHandler};
use crate::wallet::GlobalWallet;
use crate::zaps::Zaps;
@@ -48,6 +49,7 @@ pub struct Notedeck {
zaps: Zaps,
frame_history: FrameHistory,
job_pool: JobPool,
+ i18n: Localization,
}
/// Our chrome, which is basically nothing
@@ -227,6 +229,17 @@ impl Notedeck {
let zaps = Zaps::default();
let job_pool = JobPool::default();
+ // Initialize localization
+ let mut i18n = Localization::new();
+ if let Some(locale) = &parsed_args.locale {
+ if let Err(err) = i18n.set_locale(locale.to_owned()) {
+ error!("{err}");
+ }
+ }
+
+ // Initialize global i18n context
+ //crate::i18n::init_global_i18n(i18n.clone());
+
Self {
ndb,
img_cache,
@@ -246,6 +259,7 @@ impl Notedeck {
clipboard: Clipboard::new(None),
zaps,
job_pool,
+ i18n,
}
}
@@ -270,6 +284,7 @@ impl Notedeck {
zaps: &mut self.zaps,
frame_history: &mut self.frame_history,
job_pool: &mut self.job_pool,
+ i18n: &mut self.i18n,
}
}
diff --git a/crates/notedeck/src/args.rs b/crates/notedeck/src/args.rs
@@ -2,10 +2,12 @@ use std::collections::BTreeSet;
use enostr::{Keypair, Pubkey, SecretKey};
use tracing::error;
+use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
pub struct Args {
pub relays: Vec<String>,
pub is_mobile: Option<bool>,
+ pub locale: Option<LanguageIdentifier>,
pub show_note_client: bool,
pub keys: Vec<Keypair>,
pub light: bool,
@@ -36,6 +38,7 @@ impl Args {
use_keystore: true,
dbpath: None,
datapath: None,
+ locale: None,
};
let mut i = 0;
@@ -47,6 +50,23 @@ impl Args {
res.is_mobile = Some(true);
} else if arg == "--light" {
res.light = true;
+ } else if arg == "--locale" {
+ i += 1;
+ let Some(locale) = args.get(i) else {
+ panic!("locale argument missing?");
+ };
+ let parsed: Result<LanguageIdentifier, LanguageIdentifierError> = locale.parse();
+ match parsed {
+ Err(err) => {
+ panic!("locale failed to parse: {err}");
+ }
+ Ok(locale) => {
+ tracing::info!(
+ "parsed locale '{locale}' from args, not sure if we have it yet though."
+ );
+ res.locale = Some(locale);
+ }
+ }
} else if arg == "--dark" {
res.light = false;
} else if arg == "--debug" {
diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs
@@ -1,6 +1,7 @@
use crate::{
- account::accounts::Accounts, frame_history::FrameHistory, wallet::GlobalWallet, zaps::Zaps,
- Args, DataPath, Images, JobPool, NoteCache, ThemeHandler, UnknownIds,
+ account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization,
+ wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler,
+ UnknownIds,
};
use egui_winit::clipboard::Clipboard;
@@ -24,4 +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 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
@@ -0,0 +1,639 @@
+use super::{IntlError, IntlKey, IntlKeyBuf};
+use fluent::{FluentArgs, FluentBundle, FluentResource};
+use fluent_langneg::negotiate_languages;
+use std::borrow::Cow;
+use std::collections::HashMap;
+use unic_langid::{langid, LanguageIdentifier};
+
+const EN_XA: LanguageIdentifier = langid!("en-XA");
+const EN_US: LanguageIdentifier = langid!("en-US");
+const DE: LanguageIdentifier = langid!("de");
+const FR: LanguageIdentifier = langid!("FR");
+const ZH_CN: LanguageIdentifier = langid!("ZH_CN");
+const ZH_TW: LanguageIdentifier = langid!("ZH_TW");
+const NUM_FTLS: usize = 6;
+
+struct StaticBundle {
+ identifier: LanguageIdentifier,
+ ftl: &'static str,
+}
+
+const FTLS: [StaticBundle; NUM_FTLS] = [
+ StaticBundle {
+ identifier: EN_US,
+ ftl: include_str!("../../../../assets/translations/en-US/main.ftl"),
+ },
+ StaticBundle {
+ identifier: EN_XA,
+ ftl: include_str!("../../../../assets/translations/en-XA/main.ftl"),
+ },
+ StaticBundle {
+ identifier: DE,
+ ftl: include_str!("../../../../assets/translations/de/main.ftl"),
+ },
+ StaticBundle {
+ identifier: FR,
+ ftl: include_str!("../../../../assets/translations/fr/main.ftl"),
+ },
+ StaticBundle {
+ identifier: ZH_CN,
+ ftl: include_str!("../../../../assets/translations/zh-CN/main.ftl"),
+ },
+ StaticBundle {
+ identifier: ZH_TW,
+ ftl: include_str!("../../../../assets/translations/zh-TW/main.ftl"),
+ },
+];
+
+type Bundle = FluentBundle<FluentResource>;
+
+/// Manages localization resources and provides localized strings
+pub struct Localization {
+ /// Current locale
+ current_locale: LanguageIdentifier,
+ /// Available locales
+ available_locales: Vec<LanguageIdentifier>,
+ /// Fallback locale
+ fallback_locale: LanguageIdentifier,
+
+ /// Cached string results per locale (only for strings without arguments)
+ string_cache: HashMap<LanguageIdentifier, HashMap<String, String>>,
+ /// Cached normalized keys
+ normalized_key_cache: HashMap<String, IntlKeyBuf>,
+ /// Bundles
+ bundles: HashMap<LanguageIdentifier, Bundle>,
+
+ use_isolating: bool,
+}
+
+impl Default for Localization {
+ fn default() -> Self {
+ // Default to English (US)
+ let default_locale = &EN_US;
+ let fallback_locale = default_locale.to_owned();
+
+ // Build available locales list
+ let available_locales = vec![
+ EN_US.clone(),
+ EN_XA.clone(),
+ DE.clone(),
+ FR.clone(),
+ ZH_CN.clone(),
+ ZH_TW.clone(),
+ ];
+
+ Self {
+ current_locale: default_locale.to_owned(),
+ available_locales,
+ fallback_locale,
+ use_isolating: true,
+ normalized_key_cache: HashMap::new(),
+ string_cache: HashMap::new(),
+ bundles: HashMap::new(),
+ }
+ }
+}
+
+impl Localization {
+ /// Creates a new Localization with the specified resource directory
+ pub fn new() -> Self {
+ Localization::default()
+ }
+
+ /// Disable bidirectional isolation markers. mostly useful for tests
+ pub fn no_bidi() -> Self {
+ Localization {
+ use_isolating: false,
+ ..Localization::default()
+ }
+ }
+
+ /// Gets a localized string by its ID
+ 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);
+ }
+ }
+
+ // no static ftl for this LanguageIdentifier
+ Err(IntlError::NoFtl(lang.to_owned()))
+ }
+
+ fn get_bundle<'a>(&'a self, lang: &LanguageIdentifier) -> &'a Bundle {
+ self.bundles
+ .get(lang)
+ .expect("make sure to call ensure_bundle!")
+ }
+
+ fn has_bundle(&self, lang: &LanguageIdentifier) -> bool {
+ self.bundles.contains_key(lang)
+ }
+
+ fn try_load_bundle(&mut self, lang: &LanguageIdentifier) -> Result<(), IntlError> {
+ let mut bundle = Self::load_bundle(lang)?;
+ if !self.use_isolating {
+ bundle.set_use_isolating(false);
+ }
+ self.bundles.insert(lang.to_owned(), bundle);
+ 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()
+ }
+ }
+ }
+
+ 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}");
+ }
+
+ // 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!(
+ "normalize_ftl_key: original='{}', final='{}'",
+ cache_key,
+ result
+ );
+
+ 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));
+ }
+ }
+
+ 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);
+ }
+
+ self.get_bundle(&self.fallback_locale)
+ }
+
+ /// Gets cached string result, or formats it and caches the result
+ pub fn get_cached_string(
+ &mut self,
+ id: IntlKey<'_>,
+ args: Option<&FluentArgs>,
+ ) -> Result<String, IntlError> {
+ self.ensure_bundle()?;
+
+ if args.is_none() {
+ if let Ok(result) = self.get_cached_string_no_args(&self.current_locale, id) {
+ return Ok(result.to_string());
+ }
+ }
+
+ let result = {
+ let bundle = self.get_current_bundle();
+
+ let message = bundle
+ .get_message(id.as_str())
+ .ok_or_else(|| IntlError::NotFound(id.to_owned()))?;
+
+ let pattern = message
+ .value()
+ .ok_or_else(|| IntlError::NoValue(id.to_owned()))?;
+
+ 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);
+ }
+
+ 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() {
+ 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::trace!("Not caching string '{}' due to arguments", id);
+ }
+
+ Ok(result)
+ }
+
+ 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(&mut self, locale: LanguageIdentifier) -> Result<(), IntlError> {
+ tracing::info!("Attempting to set locale to: {}", locale);
+ tracing::info!("Available locales: {:?}", self.available_locales);
+
+ // Validate that the locale is available
+ if !self.available_locales.contains(&locale) {
+ tracing::error!(
+ "Locale {} is not available. Available locales: {:?}",
+ locale,
+ self.available_locales
+ );
+ return Err(IntlError::LocaleNotAvailable(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
+ 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(&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) -> &LanguageIdentifier {
+ &self.current_locale
+ }
+
+ /// Gets all available locales
+ pub fn get_available_locales(&self) -> &[LanguageIdentifier] {
+ &self.available_locales
+ }
+
+ /// Gets the fallback locale
+ pub fn get_fallback_locale(&self) -> &LanguageIdentifier {
+ &self.fallback_locale
+ }
+
+ /// Gets cache statistics for monitoring performance
+ pub fn get_cache_stats(&self) -> Result<CacheStats, Box<dyn std::error::Error + Send + Sync>> {
+ let mut total_strings = 0;
+ for locale_cache in self.string_cache.values() {
+ total_strings += locale_cache.len();
+ }
+
+ Ok(CacheStats {
+ resource_cache_size: self.bundles.len(),
+ string_cache_size: total_strings,
+ cached_locales: self.bundles.keys().cloned().collect(),
+ })
+ }
+
+ /// Limits the string cache size to prevent memory growth
+ pub fn limit_string_cache_size(
+ &mut self,
+ max_strings_per_locale: usize,
+ ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ 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
+ locale_cache.clear();
+ tracing::debug!("Cleared string cache for locale due to size limit");
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Negotiates the best locale from a list of preferred locales
+ pub fn negotiate_locale(&self, preferred: &[LanguageIdentifier]) -> LanguageIdentifier {
+ let available = self.available_locales.clone();
+ let negotiated = negotiate_languages(
+ preferred,
+ &available,
+ Some(&self.fallback_locale),
+ fluent_langneg::NegotiationStrategy::Filtering,
+ );
+ negotiated
+ .first()
+ .map_or(self.fallback_locale.clone(), |v| (*v).clone())
+ }
+}
+
+/// Statistics about cache usage
+#[derive(Debug, Clone)]
+pub struct CacheStats {
+ pub resource_cache_size: usize,
+ pub string_cache_size: usize,
+ pub cached_locales: Vec<LanguageIdentifier>,
+}
+
+#[cfg(test)]
+mod tests {
+
+ //
+ // TODO(jb55): write tests that work, i broke all these during the refacto
+ //
+
+ /*
+ use super::*;
+ #[test]
+ fn test_locale_management() {
+ let i18n = Localization::default();
+
+ // Test default locale
+ let current = i18n.get_current_locale();
+ assert_eq!(current.to_string(), "en-US");
+
+ // Test available locales
+ let available = i18n.get_available_locales();
+ assert_eq!(available.len(), 2);
+ assert_eq!(available[0].to_string(), "en-US");
+ assert_eq!(available[1].to_string(), "en-XA");
+ }
+
+ #[test]
+ fn test_cache_clearing() {
+ let mut i18n = Localization::default();
+
+ // Load and cache the FTL content
+ let result1 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
+ assert!(result1.is_ok());
+
+ // Clear the cache
+ let clear_result = i18n.clear_cache();
+ assert!(clear_result.is_ok());
+
+ // Should still work after clearing cache (will reload)
+ let result2 = i18n.get_string(IntlKeyBuf::new("test_key").borrow());
+ assert!(result2.is_ok());
+ assert_eq!(result2.unwrap(), "Test Value");
+ }
+
+ #[test]
+ fn test_context_caching() {
+ let mut i18n = Localization::default();
+
+ // Debug: check what the normalized key should be
+ 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 = i18n.get_string(normalized_key.borrow());
+ println!("First result: {:?}", result1);
+ assert!(result1.is_ok());
+ assert_eq!(result1.unwrap(), "Test Value");
+
+ // Second call should use cached FTL content
+ 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 = i18n.clear_cache();
+ assert!(clear_result.is_ok());
+
+ // Should still work after clearing cache
+ let result3 = i18n.get_string(normalized_key.borrow());
+ assert!(result3.is_ok());
+ assert_eq!(result3.unwrap(), "Test Value");
+ }
+
+
+ #[test]
+ fn test_ftl_caching() {
+ let mut i18n = Localization::default();
+
+ // First call should load and cache the FTL content
+ 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 = 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 = i18n.get_string(IntlKeyBuf::new("another_key").borrow());
+ assert!(result3.is_ok());
+ assert_eq!(result3.unwrap(), "Another Value");
+ }
+ #[test]
+ fn test_bundle_caching() {
+ let mut i18n = Localization::default();
+
+ // First call should create bundle and cache the resource
+ 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 = i18n.get_string(IntlKeyBuf::new("another_key").borrow());
+ assert!(result2.is_ok());
+ assert_eq!(result2.unwrap(), "Another Value");
+
+ // Check cache stats
+ 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
+ }
+
+ #[test]
+ fn test_string_caching() {
+ let mut i18n = Localization::default();
+ let key = i18n.normalized_ftl_key("test_key", "comment");
+
+ // First call should format and cache the string
+ 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 = i18n.get_string(key.borrow());
+ assert!(result2.is_ok());
+ assert_eq!(result2.unwrap(), "Test Value");
+
+ // Check cache stats
+ let stats = i18n.get_cache_stats().unwrap();
+ assert_eq!(stats.string_cache_size, 1);
+ }
+ #[test]
+ fn test_string_caching_with_arguments() {
+ let mut manager = Localization::default();
+
+ // First call with arguments should not be cached
+ let mut args = fluent::FluentArgs::new();
+ args.set("name", "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();
+ assert_eq!(stats1.string_cache_size, 0);
+
+ // Second call with different arguments should work correctly
+ let mut args2 = fluent::FluentArgs::new();
+ args2.set("name", "Bob");
+ let result2 = manager.get_cached_string(key.borrow(), Some(&args2));
+ assert!(result2.is_ok());
+ let result2_str = result2.unwrap();
+ assert!(result2_str.contains("Bob"));
+
+ // Check that it's still not in the string cache
+ let stats2 = manager.get_cache_stats().unwrap();
+ assert_eq!(stats2.string_cache_size, 0);
+
+ // Clear cache to start fresh
+ manager.clear_cache().unwrap();
+
+ 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);
+ }
+
+ #[test]
+ fn test_cache_clearing_on_locale_change() {
+ let mut i18n = Localization::default();
+
+ // Check that caches are populated
+ 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 = langid!("en-XA");
+ i18n.set_locale(en_xa).unwrap();
+
+ // Check that string cache is cleared (resource cache remains for both locales)
+ let stats2 = i18n.get_cache_stats().unwrap();
+ assert_eq!(stats2.string_cache_size, 0);
+ }
+ */
+}
+
+/// 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
@@ -0,0 +1,107 @@
+//! Internationalization (i18n) module for Notedeck
+//!
+//! This module provides localization support using fluent and fluent-resmgr.
+//! 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::Localization;
+
+/// Re-export commonly used types for convenience
+pub use fluent::FluentArgs;
+pub use fluent::FluentValue;
+pub use unic_langid::LanguageIdentifier;
+
+/// Macro for getting localized strings with format-like syntax
+///
+/// Syntax: tr!("message", comment)
+/// tr!("message with {param}", comment, param="value")
+/// tr!("message with {first} and {second}", comment, first="value1", second="value2")
+///
+/// The first argument is the source message (like format!).
+/// The second argument is always the comment to provide context for translators.
+/// If `{name}` placeholders are found, there must be corresponding named arguments after the comment.
+/// All placeholders must be named and start with a letter (a-zA-Z).
+#[macro_export]
+macro_rules! tr {
+ ($i18n:expr, $message:expr, $comment:expr) => {
+ {
+ let key = $i18n.normalized_ftl_key($message, $comment);
+ match $i18n.get_string(key.borrow()) {
+ Ok(r) => r,
+ Err(_err) => {
+ $message.to_string()
+ }
+ }
+ }
+ };
+
+ // Case with named parameters: message, comment, param=value, ...
+ ($i18n:expr, $message:expr, $comment:expr, $($param:ident = $value:expr),*) => {
+ {
+ 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
+ }
+ }
+ }
+ };
+}
+
+/// Macro for getting localized pluralized strings with count and named arguments
+///
+/// Syntax: tr_plural!(one, other, comment, count, param1=..., param2=...)
+/// - one: Message for the singular ("one") plural rule
+/// - other: Message for the "other" plural rule
+/// - comment: Context for translators
+/// - count: The count value
+/// - named arguments: Any additional named parameters for interpolation
+#[macro_export]
+macro_rules! tr_plural {
+ // With named parameters
+ ($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
+ }
+ }
+ }
+ }};
+ // Without named parameters
+ ($one:expr, $other:expr, $comment:expr, $count:expr) => {{
+ $crate::tr_plural!($one, $other, $comment, $count, )
+ }};
+}
diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs
@@ -9,6 +9,7 @@ mod error;
pub mod filter;
pub mod fonts;
mod frame_history;
+pub mod i18n;
mod imgcache;
mod job_pool;
mod muted;
@@ -44,6 +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::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization};
pub use imgcache::{
Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache,
MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache,
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,11 +1,24 @@
+use crate::{tr, Localization};
use std::time::{SystemTime, UNIX_EPOCH};
-pub fn time_ago_since(timestamp: u64) -> String {
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("Time went backwards")
- .as_secs();
+// Time duration constants in seconds
+const ONE_MINUTE_IN_SECONDS: u64 = 60;
+const ONE_HOUR_IN_SECONDS: u64 = 3600;
+const ONE_DAY_IN_SECONDS: u64 = 86_400;
+const ONE_WEEK_IN_SECONDS: u64 = 604_800;
+const ONE_MONTH_IN_SECONDS: u64 = 2_592_000; // 30 days
+const ONE_YEAR_IN_SECONDS: u64 = 31_536_000; // 365 days
+// Range boundary constants for match patterns
+const MAX_SECONDS: u64 = ONE_MINUTE_IN_SECONDS - 1;
+const MAX_SECONDS_FOR_MINUTES: u64 = ONE_HOUR_IN_SECONDS - 1;
+const MAX_SECONDS_FOR_HOURS: u64 = ONE_DAY_IN_SECONDS - 1;
+const MAX_SECONDS_FOR_DAYS: u64 = ONE_WEEK_IN_SECONDS - 1;
+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(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)
@@ -13,43 +26,321 @@ pub fn time_ago_since(timestamp: u64) -> String {
timestamp.saturating_sub(now)
};
- let future = timestamp > now;
- let relstr = if future { "+" } else { "" };
+ let time_str = match duration {
+ 0..=2 => tr!(
+ i18n,
+ "now",
+ "Relative time for very recent events (less than 3 seconds)"
+ ),
+ 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
+ ),
+ };
- let years = duration / 31_536_000; // seconds in a year
- if years >= 1 {
- return format!("{relstr}{years}yr");
+ if timestamp > now {
+ format!("+{time_str}")
+ } else {
+ time_str
}
+}
- let months = duration / 2_592_000; // seconds in a month (30.44 days)
- if months >= 1 {
- return format!("{relstr}{months}mth");
+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(i18n, timestamp, now)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ fn get_current_timestamp() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("Time went backwards")
+ .as_secs()
}
- let weeks = duration / 604_800; // seconds in a week
- if weeks >= 1 {
- return format!("{relstr}{weeks}wk");
+ #[test]
+ fn test_now_condition() {
+ let now = get_current_timestamp();
+ let mut intl = Localization::no_bidi();
+
+ // Test 0 seconds ago
+ let result = time_ago_between(&mut intl, now, now);
+ assert_eq!(
+ result, "now",
+ "Expected 'now' for 0 seconds, got: {}",
+ result
+ );
+
+ // Test 1 second ago
+ let result = time_ago_between(&mut intl, now - 1, now);
+ assert_eq!(
+ result, "now",
+ "Expected 'now' for 1 second, got: {}",
+ result
+ );
+
+ // Test 2 seconds ago
+ let result = time_ago_between(&mut intl, now - 2, now);
+ assert_eq!(
+ result, "now",
+ "Expected 'now' for 2 seconds, got: {}",
+ result
+ );
}
- let days = duration / 86_400; // seconds in a day
- if days >= 1 {
- return format!("{relstr}{days}d");
+ #[test]
+ fn test_seconds_condition() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 3 seconds ago
+ 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(&mut i18n, now - 30, now);
+ assert_eq!(
+ result, "30s",
+ "Expected '30s' for 30 seconds, got: {}",
+ result
+ );
+
+ // Test 59 seconds ago (max for seconds)
+ let result = time_ago_between(&mut i18n, now - 59, now);
+ assert_eq!(
+ result, "59s",
+ "Expected '59s' for 59 seconds, got: {}",
+ result
+ );
}
- let hours = duration / 3600; // seconds in an hour
- if hours >= 1 {
- return format!("{relstr}{hours}h");
+ #[test]
+ fn test_minutes_condition() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 1 minute ago
+ 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(&mut i18n, now - 30 * ONE_MINUTE_IN_SECONDS, now);
+ assert_eq!(
+ result, "30m",
+ "Expected '30m' for 30 minutes, got: {}",
+ result
+ );
+
+ // Test 59 minutes ago (max for minutes)
+ let result = time_ago_between(&mut i18n, now - 59 * ONE_MINUTE_IN_SECONDS, now);
+ assert_eq!(
+ result, "59m",
+ "Expected '59m' for 59 minutes, got: {}",
+ result
+ );
}
- let minutes = duration / 60; // seconds in a minute
- if minutes >= 1 {
- return format!("{relstr}{minutes}m");
+ #[test]
+ fn test_hours_condition() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 1 hour ago
+ 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(&mut i18n, now - 12 * ONE_HOUR_IN_SECONDS, now);
+ assert_eq!(
+ result, "12h",
+ "Expected '12h' for 12 hours, got: {}",
+ result
+ );
+
+ // Test 23 hours ago (max for hours)
+ let result = time_ago_between(&mut i18n, now - 23 * ONE_HOUR_IN_SECONDS, now);
+ assert_eq!(
+ result, "23h",
+ "Expected '23h' for 23 hours, got: {}",
+ result
+ );
+ }
+
+ #[test]
+ fn test_days_condition() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 1 day ago
+ 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(&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(&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::no_bidi();
+
+ // Test 1 week ago
+ 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(&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::no_bidi();
+
+ // Test 1 month ago
+ 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(&mut i18n, now - 11 * ONE_MONTH_IN_SECONDS, now);
+ assert_eq!(
+ result, "11mo",
+ "Expected '11mo' for 11 months, got: {}",
+ result
+ );
}
- let seconds = duration;
- if seconds >= 3 {
- return format!("{relstr}{seconds}s");
+ #[test]
+ fn test_years_condition() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 1 year ago
+ 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(&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(&mut i18n, now - 10 * ONE_YEAR_IN_SECONDS, now);
+ assert_eq!(
+ result, "10y",
+ "Expected '10y' for 10 years, got: {}",
+ result
+ );
}
- "now".to_string()
+ #[test]
+ fn test_future_timestamps() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test 1 minute in the future
+ 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: {}",
+ result
+ );
+
+ // Test 1 hour in the future
+ 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: {}",
+ result
+ );
+
+ // Test 1 day in the future
+ 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: {}",
+ result
+ );
+ }
+
+ #[test]
+ fn test_boundary_conditions() {
+ let now = get_current_timestamp();
+ let mut i18n = Localization::no_bidi();
+
+ // Test boundary between seconds and minutes
+ let result = time_ago_between(&mut i18n, now - 60, now);
+ assert_eq!(
+ result, "1m",
+ "Expected '1m' for exactly 60 seconds, got: {}",
+ result
+ );
+
+ // Test boundary between minutes and hours
+ let result = time_ago_between(&mut i18n, now - 3600, now);
+ assert_eq!(
+ result, "1h",
+ "Expected '1h' for exactly 3600 seconds, got: {}",
+ result
+ );
+
+ // Test boundary between hours and days
+ let result = time_ago_between(&mut i18n, now - 86400, now);
+ assert_eq!(
+ result, "1d",
+ "Expected '1d' for exactly 86400 seconds, got: {}",
+ result
+ );
+ }
}
diff --git a/crates/notedeck/src/wallet.rs b/crates/notedeck/src/wallet.rs
@@ -153,8 +153,8 @@ impl From<nwc::Error> for NwcError {
impl Display for NwcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- NwcError::NIP47(err) => write!(f, "NIP47 error: {}", err),
- NwcError::Relay(err) => write!(f, "Relay error: {}", err),
+ NwcError::NIP47(err) => write!(f, "NIP47 error: {err}"),
+ NwcError::Relay(err) => write!(f, "Relay error: {err}"),
NwcError::PrematureExit => write!(f, "Premature exit"),
NwcError::Timeout => write!(f, "Request timed out"),
}
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::{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,15 +466,17 @@ fn milestone_name() -> impl Widget {
);
ui.add(
Label::new(
- RichText::new("BETA")
+ RichText::new(tr!(i18n, "BETA", "Beta version label"))
.color(ui.style().visuals.noninteractive().fg_stroke.color)
.font(font),
)
.selectable(false),
)
- .on_hover_text(
+ .on_hover_text(tr!(
+ i18n,
"Notedeck is a beta product. Expect bugs and contact us when you run into issues.",
- )
+ "Beta product warning message"
+ ))
.on_hover_cursor(egui::CursorIcon::Help)
})
.inner
@@ -656,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,
@@ -719,7 +727,11 @@ fn bottomup_sidebar(
let resp = ui
.add(Button::new("☀").frame(false))
.on_hover_cursor(egui::CursorIcon::PointingHand)
- .on_hover_text("Switch to light mode");
+ .on_hover_text(tr!(
+ ctx.i18n,
+ "Switch to light mode",
+ "Hover text for light mode toggle button"
+ ));
if resp.clicked() {
Some(ChromePanelAction::SaveTheme(ThemePreference::Light))
} else {
@@ -730,7 +742,11 @@ fn bottomup_sidebar(
let resp = ui
.add(Button::new("🌙").frame(false))
.on_hover_cursor(egui::CursorIcon::PointingHand)
- .on_hover_text("Switch to dark mode");
+ .on_hover_text(tr!(
+ ctx.i18n,
+ "Switch to dark mode",
+ "Hover text for dark mode toggle button"
+ ));
if resp.clicked() {
Some(ChromePanelAction::SaveTheme(ThemePreference::Dark))
} else {
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
@@ -19,7 +19,8 @@ use egui_extras::{Size, StripBuilder};
use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
use nostrdb::Transaction;
use notedeck::{
- ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, UnknownIds,
+ tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState,
+ Localization, UnknownIds,
};
use notedeck_ui::{jobs::JobsCache, NoteOptions};
use std::collections::{BTreeSet, HashMap};
@@ -91,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();
@@ -186,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 {
@@ -261,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(());
@@ -426,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 {}",
@@ -494,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 {
@@ -511,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);
@@ -566,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,
@@ -622,6 +627,7 @@ fn hovering_post_button(
&mut app.decks_cache,
app_ctx.accounts,
SidePanelAction::ComposeNote,
+ app_ctx.i18n,
);
}
}
@@ -714,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() {
@@ -724,6 +733,7 @@ fn timelines_view(
&mut app.decks_cache,
ctx.accounts,
side_panel.action,
+ ctx.i18n,
) {
side_panel_action = Some(action);
}
@@ -832,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,
- "My Deck".to_owned(),
+ 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::{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: ¬edeck::Accounts) -> Option<&mut Column> {
- self.active_columns_mut(accounts)
+ pub fn selected_column_mut(
+ &mut self,
+ i18n: &mut Localization,
+ accounts: ¬edeck::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: ¬edeck::Accounts) -> Option<&mut Columns> {
+ pub fn active_columns_mut(
+ &mut self,
+ i18n: &mut Localization,
+ accounts: ¬edeck::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() -> &'static str {
- "Default Deck"
+ 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,6 +2,7 @@ use crate::key_parsing::perform_key_retrieval;
use crate::key_parsing::AcquireKeyError;
use egui::{TextBuffer, TextEdit};
use enostr::Keypair;
+use notedeck::{tr, Localization};
use poll_promise::Promise;
/// The state data for acquiring a nostr key
@@ -23,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)
}
@@ -105,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| {
@@ -115,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);
@@ -130,11 +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("Invalid key.").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
@@ -31,8 +31,8 @@ use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, P
use enostr::ProfileState;
use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{
- get_current_default_msats, get_current_wallet, ui::is_narrow, Accounts, AppContext, NoteAction,
- NoteContext, RelayAction,
+ get_current_default_msats, get_current_wallet, tr, ui::is_narrow, Accounts, AppContext,
+ NoteAction, NoteContext, RelayAction,
};
use tracing::error;
@@ -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,21 +570,29 @@ 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) => {
let txn = if let Ok(txn) = Transaction::new(ctx.ndb) {
txn
} else {
- ui.label("Reply to unknown note");
+ ui.label(tr!(
+ note_context.i18n,
+ "Reply to unknown note",
+ "Error message when reply note cannot be found"
+ ));
return None;
};
let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) {
note
} else {
- ui.label("Reply to unknown note");
+ ui.label(tr!(
+ note_context.i18n,
+ "Reply to unknown note",
+ "Error message when reply note cannot be found"
+ ));
return None;
};
@@ -616,7 +629,11 @@ fn render_nav_body(
let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) {
note
} else {
- ui.label("Quote of unknown note");
+ ui.label(tr!(
+ note_context.i18n,
+ "Quote of unknown note",
+ "Error message when quote note cannot be found"
+ ));
return None;
};
@@ -667,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");
@@ -702,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;
@@ -717,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();
}
@@ -725,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");
@@ -737,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);
@@ -748,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();
}
@@ -769,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()),
@@ -824,7 +842,7 @@ fn render_nav_body(
}
};
- WalletView::new(state)
+ WalletView::new(state, ctx.i18n)
.ui(ui)
.map(RenderNavAction::WalletAction)
}
@@ -832,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,
@@ -840,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();
@@ -895,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)
@@ -917,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,
@@ -933,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::{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,45 +235,107 @@ impl Route {
)
}
- pub fn title(&self) -> ColumnTitle<'_> {
+ pub fn title(&self, i18n: &mut Localization) -> ColumnTitle<'_> {
match self {
- Route::Timeline(kind) => kind.to_title(),
- Route::Thread(_) => ColumnTitle::simple("Thread"),
- Route::Reply(_id) => ColumnTitle::simple("Reply"),
- Route::Quote(_id) => ColumnTitle::simple("Quote"),
- Route::Relays => ColumnTitle::simple("Relays"),
+ Route::Timeline(kind) => kind.to_title(i18n),
+ Route::Thread(_) => {
+ ColumnTitle::formatted(tr!(i18n, "Thread", "Column title for note thread view"))
+ }
+ Route::Reply(_id) => {
+ ColumnTitle::formatted(tr!(i18n, "Reply", "Column title for reply composition"))
+ }
+ Route::Quote(_id) => {
+ ColumnTitle::formatted(tr!(i18n, "Quote", "Column title for quote composition"))
+ }
+ Route::Relays => {
+ ColumnTitle::formatted(tr!(i18n, "Relays", "Column title for relay management"))
+ }
Route::Accounts(amr) => match amr {
- AccountsRoute::Accounts => ColumnTitle::simple("Accounts"),
- AccountsRoute::AddAccount => ColumnTitle::simple("Add Account"),
+ 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::simple("Compose Note"),
+ Route::ComposeNote => ColumnTitle::formatted(tr!(
+ i18n,
+ "Compose Note",
+ "Column title for note composition"
+ )),
Route::AddColumn(c) => match c {
- AddColumnRoute::Base => ColumnTitle::simple("Add Column"),
+ AddColumnRoute::Base => ColumnTitle::formatted(tr!(
+ i18n,
+ "Add Column",
+ "Column title for adding new column"
+ )),
AddColumnRoute::Algo(r) => match r {
- AddAlgoRoute::Base => ColumnTitle::simple("Add Algo Column"),
- AddAlgoRoute::LastPerPubkey => ColumnTitle::simple("Add Last Notes Column"),
+ 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::simple("Add Notifications Column")
- }
- AddColumnRoute::ExternalNotification => {
- ColumnTitle::simple("Add External Notifications Column")
- }
- AddColumnRoute::Hashtag => ColumnTitle::simple("Add Hashtag Column"),
- AddColumnRoute::UndecidedIndividual => {
- ColumnTitle::simple("Subscribe to someone's notes")
- }
- AddColumnRoute::ExternalIndividual => {
- ColumnTitle::simple("Subscribe to someone else's notes")
- }
+ 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::simple("Damus Support"),
- Route::NewDeck => ColumnTitle::simple("Add Deck"),
- Route::EditDeck(_) => ColumnTitle::simple("Edit Deck"),
- Route::EditProfile(_) => ColumnTitle::simple("Edit Profile"),
- Route::Search => ColumnTitle::simple("Search"),
- Route::Wallet(_) => ColumnTitle::simple("Wallet"),
- Route::CustomizeZapAmount(_) => ColumnTitle::simple("Customize Zap Amount"),
+ Route::Support => {
+ ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page"))
+ }
+ Route::NewDeck => {
+ ColumnTitle::formatted(tr!(i18n, "Add Deck", "Column title for adding new deck"))
+ }
+ Route::EditDeck(_) => {
+ ColumnTitle::formatted(tr!(i18n, "Edit Deck", "Column title for editing deck"))
+ }
+ 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::Wallet(_) => {
+ 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"
+ )),
}
}
}
@@ -449,41 +505,99 @@ 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, "Home"),
+ TimelineKind::List(ListKind::Contact(_pk)) => {
+ write!(f, "{}", i18n, "Home", "Display name for home feed"))
+ }
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(_))) => {
- write!(f, "Last Per Pubkey (Contact)")
+ write!(
+ f,
+ "{}",
+ tr!(
+ "Last Per Pubkey (Contact)",
+ "Display name for last notes per contact"
+ )
+ )
+ }
+ TimelineKind::Notifications(_) => write!(
+ f,
+ "{}",
+ tr!("Notifications", "Display name for notifications")
+ ),
+ TimelineKind::Universe => {
+ write!(f, "{}", tr!("Universe", "Display name for universe feed"))
+ }
+ TimelineKind::Generic(_) => {
+ write!(f, "{}", tr!("Custom", "Display name for custom timelines"))
+ }
+ TimelineKind::Search(_) => {
+ write!(f, "{}", tr!("Search", "Display name for search results"))
+ }
+ TimelineKind::Hashtag(ht) => write!(
+ f,
+ "{} ({})",
+ tr!("Hashtags", "Display name for hashtag feeds"),
+ ht.join(" ")
+ ),
+ TimelineKind::Profile(_id) => {
+ write!(f, "{}", tr!("Profile", "Display name for user profiles"))
}
- TimelineKind::Notifications(_) => write!(f, "Notifications"),
- TimelineKind::Universe => write!(f, "Universe"),
- TimelineKind::Generic(_) => write!(f, "Custom"),
- TimelineKind::Search(_) => write!(f, "Search"),
- TimelineKind::Hashtag(ht) => write!(f, "Hashtags ({})", ht.join(" ")),
- TimelineKind::Profile(_id) => write!(f, "Profile"),
},
- Route::Thread(_) => write!(f, "Thread"),
- Route::Reply(_id) => write!(f, "Reply"),
- Route::Quote(_id) => write!(f, "Quote"),
- Route::Relays => write!(f, "Relays"),
+ Route::Thread(_) => write!(f, "{}", tr!("Thread", "Display name for thread view")),
+ Route::Reply(_id) => {
+ write!(f, "{}", tr!("Reply", "Display name for reply composition"))
+ }
+ Route::Quote(_id) => {
+ write!(f, "{}", tr!("Quote", "Display name for quote composition"))
+ }
+ Route::Relays => write!(f, "{}", tr!("Relays", "Display name for relay management")),
Route::Accounts(amr) => match amr {
- AccountsRoute::Accounts => write!(f, "Accounts"),
- AccountsRoute::AddAccount => write!(f, "Add Account"),
+ AccountsRoute::Accounts => write!(
+ f,
+ "{}",
+ tr!("Accounts", "Display name for account management")
+ ),
+ AccountsRoute::AddAccount => write!(
+ f,
+ "{}",
+ tr!("Add Account", "Display name for adding account")
+ ),
},
- Route::ComposeNote => write!(f, "Compose Note"),
- Route::AddColumn(_) => write!(f, "Add Column"),
- Route::Support => write!(f, "Support"),
- Route::NewDeck => write!(f, "Add Deck"),
- Route::EditDeck(_) => write!(f, "Edit Deck"),
- Route::EditProfile(_) => write!(f, "Edit Profile"),
- Route::Search => write!(f, "Search"),
- Route::Wallet(_) => write!(f, "Wallet"),
- Route::CustomizeZapAmount(_) => write!(f, "Customize Zap Amount"),
+ Route::ComposeNote => write!(
+ f,
+ "{}",
+ tr!("Compose Note", "Display name for note composition")
+ ),
+ Route::AddColumn(_) => {
+ write!(f, "{}", tr!("Add Column", "Display name for adding column"))
+ }
+ Route::Support => write!(f, "{}", tr!("Support", "Display name for support page")),
+ Route::NewDeck => write!(f, "{}", tr!("Add Deck", "Display name for adding deck")),
+ Route::EditDeck(_) => {
+ write!(f, "{}", tr!("Edit Deck", "Display name for editing deck"))
+ }
+ Route::EditProfile(_) => write!(
+ f,
+ "{}",
+ tr!("Edit Profile", "Display name for profile editing")
+ ),
+ Route::Search => write!(f, "{}", tr!("Search", "Display name for search page")),
+ Route::Wallet(_) => {
+ write!(f, "{}", tr!("Wallet", "Display name for wallet management"))
+ }
+ Route::CustomizeZapAmount(_) => write!(
+ f,
+ "{}",
+ tr!("Customize Zap Amount", "Display name for zap customization")
+ ),
}
}
}
+*/
#[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},
- 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,20 +254,55 @@ impl AlgoTimeline {
}
}
+/*
impl Display for TimelineKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- TimelineKind::List(ListKind::Contact(_src)) => f.write_str("Home"),
- TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => f.write_str("Last Notes"),
- TimelineKind::Generic(_) => f.write_str("Timeline"),
- TimelineKind::Notifications(_) => f.write_str("Notifications"),
- TimelineKind::Profile(_) => f.write_str("Profile"),
- TimelineKind::Universe => f.write_str("Universe"),
- TimelineKind::Hashtag(_) => f.write_str("Hashtags"),
- TimelineKind::Search(_) => f.write_str("Search"),
+ TimelineKind::List(ListKind::Contact(_src)) => write!(
+ f,
+ "{}",
+ tr!("Home", "Timeline kind label for contact lists")
+ ),
+ TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => write!(
+ f,
+ "{}",
+ tr!(
+ "Last Notes",
+ "Timeline kind label for last notes per pubkey"
+ )
+ ),
+ TimelineKind::Generic(_) => {
+ write!(f, "{}", tr!("Timeline", "Generic timeline kind label"))
+ }
+ TimelineKind::Notifications(_) => write!(
+ f,
+ "{}",
+ tr!("Notifications", "Timeline kind label for notifications")
+ ),
+ TimelineKind::Profile(_) => write!(
+ f,
+ "{}",
+ tr!("Profile", "Timeline kind label for user profiles")
+ ),
+ TimelineKind::Universe => write!(
+ f,
+ "{}",
+ tr!("Universe", "Timeline kind label for universe feed")
+ ),
+ TimelineKind::Hashtag(_) => write!(
+ f,
+ "{}",
+ tr!("Hashtag", "Timeline kind label for hashtag feeds")
+ ),
+ TimelineKind::Search(_) => write!(
+ f,
+ "{}",
+ tr!("Search", "Timeline kind label for search results")
+ ),
}
}
}
+*/
impl TimelineKind {
pub fn pubkey(&self) -> Option<&Pubkey> {
@@ -561,21 +596,33 @@ 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::simple("Contacts"),
+ ListKind::Contact(_pubkey_source) => {
+ ColumnTitle::formatted(tr!(i18n, "Contacts", "Column title for contact lists"))
+ }
},
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(list_kind)) => match list_kind {
- ListKind::Contact(_pubkey_source) => ColumnTitle::simple("Contacts (last notes)"),
+ ListKind::Contact(_pubkey_source) => ColumnTitle::formatted(tr!(
+ i18n,
+ "Contacts (last notes)",
+ "Column title for last notes per contact"
+ )),
},
- TimelineKind::Notifications(_pubkey_source) => ColumnTitle::simple("Notifications"),
+ TimelineKind::Notifications(_pubkey_source) => {
+ ColumnTitle::formatted(tr!(i18n, "Notifications", "Column title for notifications"))
+ }
TimelineKind::Profile(_pubkey_source) => ColumnTitle::needs_db(self),
- TimelineKind::Universe => ColumnTitle::simple("Universe"),
- TimelineKind::Generic(_) => ColumnTitle::simple("Custom"),
+ TimelineKind::Universe => {
+ ColumnTitle::formatted(tr!(i18n, "Universe", "Column title for universe feed"))
+ }
+ TimelineKind::Generic(_) => {
+ 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},
- 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,10 +64,16 @@ pub enum ViewFilter {
}
impl ViewFilter {
- pub fn name(&self) -> &'static str {
+ pub fn name(&self, i18n: &mut Localization) -> String {
match self {
- ViewFilter::Notes => "Notes",
- ViewFilter::NotesAndReplies => "Notes & Replies",
+ ViewFilter::Notes => tr!(i18n, "Notes", "Filter label for notes only view"),
+ ViewFilter::NotesAndReplies => {
+ tr!(
+ i18n,
+ "Notes & Replies",
+ "Filter label for notes and replies view"
+ )
+ }
}
}
@@ -633,6 +639,8 @@ fn setup_initial_timeline(
lim += filter.limit().unwrap_or(1) as i32;
}
+ debug!("setup_initial_timeline: limit for local filter is {}", lim);
+
let notes: Vec<NoteRef> = ndb
.query(txn, filters.local(), lim)?
.into_iter()
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, 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("Enter your public key (npub), nostr address (e.g. vrod@damus.io), or private key (nsec). You must enter your private key to be able to post, reply, etc.")
+ 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("New to Nostr?")
+ 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("Create 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("Login")
+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("Enter your key")
+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("Login now — let's do this!")
+ RichText::new(tr!(i18n, "Login now — let's do this!", "Login button text"))
.text_style(NotedeckTextStyle::Body.text_style())
.strong(),
)
@@ -120,11 +128,19 @@ 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("Your key here...").text_style(NotedeckTextStyle::Body.text_style()),
+ RichText::new(tr!(
+ i18n,
+ "Your key here...",
+ "Placeholder text for key input field"
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.min_size(Vec2::new(0.0, 40.0))
@@ -163,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::{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,17 +179,25 @@ 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(" Add 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> {
- egui::Button::new(RichText::new("Sign out"))
+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::{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,12 @@ impl<'a> AddColumnView<'a> {
deck_author: Pubkey,
) -> Option<AddColumnResponse> {
let algo_option = ColumnOptionData {
- title: "Contact List",
- description: "Source the last note for each user in your contact list",
+ 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"
+ ),
icon: app_images::home_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided(
ListKind::contact_list(deck_author),
@@ -245,8 +252,16 @@ impl<'a> AddColumnView<'a> {
fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
let algo_option = ColumnOptionData {
- title: "Last Note per User",
- description: "Show the last note for each user from a list",
+ 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"
+ ),
icon: app_images::algo_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
};
@@ -291,8 +306,12 @@ impl<'a> AddColumnView<'a> {
let text_edit = key_state.get_acquire_textedit(|text| {
egui::TextEdit::singleline(text)
.hint_text(
- RichText::new("Enter the user's key (npub, hex, nip05) here...")
- .text_style(NotedeckTextStyle::Body.text_style()),
+ RichText::new(tr!(
+ self.i18n,
+ "Enter the user's key (npub, hex, nip05) here...",
+ "Hint text to prompt entering the user's public key."
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.desired_width(f32::INFINITY)
@@ -303,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();
}
@@ -328,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 {
@@ -386,7 +407,8 @@ impl<'a> AddColumnView<'a> {
title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding)
};
- let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height));
+ let title = data.title.clone();
+ let helper = AnimationHelper::new(ui, title.clone(), vec2(max_width, max_height));
let animation_rect = helper.get_animation_rect();
let cur_icon_width = helper.scale_1d_pos(min_icon_width);
@@ -442,11 +464,15 @@ 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: "Home",
- description: "See notes from your contacts",
+ title: tr!(self.i18n, "Home", "Title for Home column"),
+ description: tr!(
+ self.i18n,
+ "See notes from your contacts",
+ "Description for Home column"
+ ),
icon: app_images::home_image(),
option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() {
PubkeySource::DeckAuthor
@@ -455,32 +481,52 @@ impl<'a> AddColumnView<'a> {
}),
});
vec.push(ColumnOptionData {
- title: "Notifications",
- description: "Stay up to date with notifications and mentions",
+ 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"
+ ),
icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::UndecidedNotification,
});
vec.push(ColumnOptionData {
- title: "Universe",
- description: "See the whole nostr universe",
+ title: tr!(self.i18n, "Universe", "Title for universe column"),
+ description: tr!(
+ self.i18n,
+ "See the whole nostr universe",
+ "Description for universe column"
+ ),
icon: app_images::universe_image(),
option: AddColumnOption::Universe,
});
vec.push(ColumnOptionData {
- title: "Hashtags",
- description: "Stay up to date with a certain hashtag",
+ 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"
+ ),
icon: app_images::hashtag_image(),
option: AddColumnOption::UndecidedHashtag,
});
vec.push(ColumnOptionData {
- title: "Individual",
- description: "Stay up to date with someone's notes & replies",
+ 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"
+ ),
icon: app_images::profile_image(),
option: AddColumnOption::UndecidedIndividual,
});
vec.push(ColumnOptionData {
- title: "Algo",
- description: "Algorithmic feeds to aid in note discovery",
+ 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"
+ ),
icon: app_images::algo_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
});
@@ -488,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() {
@@ -498,15 +544,31 @@ impl<'a> AddColumnView<'a> {
};
vec.push(ColumnOptionData {
- title: "Your Notifications",
- description: "Stay up to date with your notifications and mentions",
+ 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"
+ ),
icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::Notification(source),
});
vec.push(ColumnOptionData {
- title: "Someone else's Notifications",
- description: "Stay up to date with someone else's notifications and mentions",
+ 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"
+ ),
icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::ExternalNotification,
});
@@ -514,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() {
@@ -524,15 +586,27 @@ impl<'a> AddColumnView<'a> {
};
vec.push(ColumnOptionData {
- title: "Your Notes",
- description: "Keep track of your notes & replies",
+ 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"
+ ),
icon: app_images::profile_image(),
option: AddColumnOption::Individual(source),
});
vec.push(ColumnOptionData {
- title: "Someone else's Notes",
- description: "Stay up to date with someone else's notes & replies",
+ 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"
+ ),
icon: app_images::profile_image(),
option: AddColumnOption::ExternalIndividual,
});
@@ -541,12 +615,16 @@ impl<'a> AddColumnView<'a> {
}
}
-fn find_user_button() -> impl Widget {
- styled_button("Find User", notedeck_ui::colors::PINK)
+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 {
- styled_button("Add", notedeck_ui::colors::PINK)
+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)
}
/*
@@ -571,8 +649,8 @@ pub(crate) fn sized_button(text: &str) -> impl Widget + '_ {
*/
struct ColumnOptionData {
- title: &'static str,
- description: &'static str,
+ title: String,
+ description: String,
icon: Image<'static>,
option: AddColumnOption,
}
@@ -589,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),
@@ -599,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),
};
@@ -627,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()));
@@ -639,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(
@@ -648,7 +727,7 @@ pub fn render_add_column_routes(
}
// We have a decision on where we want the last per pubkey
- // source to be, so let;s create a timeline from that and
+ // source to be, so let's create a timeline from that and
// add it to our list of timelines
AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => {
let txn = Transaction::new(ctx.ndb).unwrap();
@@ -667,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()));
@@ -685,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(
@@ -699,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(
@@ -713,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(
@@ -726,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| {
@@ -734,8 +814,12 @@ pub fn hashtag_ui(
let text_edit = egui::TextEdit::singleline(text_buffer)
.hint_text(
- RichText::new("Enter the desired hashtags here (for multiple space-separated)")
- .text_style(NotedeckTextStyle::Body.text_style()),
+ RichText::new(tr!(
+ i18n,
+ "Enter the desired hashtags here (for multiple space-separated)",
+ "Placeholder for hashtag input field"
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.desired_width(f32::INFINITY)
@@ -748,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;
@@ -790,7 +874,7 @@ mod tests {
let data_str = "column:algo_selection:last_per_pubkey";
let data = &data_str.split(":").collect::<Vec<&str>>();
let mut token_writer = TokenWriter::default();
- let mut parser = TokenParser::new(&data);
+ let mut parser = TokenParser::new(data);
let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap();
let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey);
parsed.serialize_tokens(&mut token_writer);
diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs
@@ -12,7 +12,8 @@ use crate::{
use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder};
use enostr::Pubkey;
use nostrdb::{Ndb, Transaction};
-use notedeck::{Images, NotedeckTextStyle};
+use notedeck::tr;
+use notedeck::{Images, Localization, NotedeckTextStyle};
use notedeck_ui::app_images;
use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
@@ -26,6 +27,7 @@ pub struct NavTitle<'a> {
routes: &'a [Route],
col_id: usize,
options: u32,
+ i18n: &'a mut Localization,
}
impl<'a> NavTitle<'a> {
@@ -39,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 {
@@ -48,6 +51,7 @@ impl<'a> NavTitle<'a> {
routes,
col_id,
options,
+ i18n,
}
}
@@ -128,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)),
@@ -181,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);
@@ -192,12 +196,20 @@ 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("Confirm");
+ 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("Cancel").clicked() {
+ if confirm_resp.clicked()
+ || ui
+ .button(tr!(self.i18n, "Cancel", "Button label to cancel an action"))
+ .clicked()
+ {
ui.data_mut(|d| d.insert_temp(id, false));
}
});
@@ -206,7 +218,11 @@ impl<'a> NavTitle<'a> {
}
confirm_pressed
} else {
- delete_button_resp.on_hover_text("Delete this column");
+ delete_button_resp.on_hover_text(tr!(
+ self.i18n,
+ "Delete this column",
+ "Tooltip for deleting a column"
+ ));
false
}
}
@@ -220,7 +236,11 @@ 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("Moves this column to another positon");
+ move_resp = move_resp.on_hover_text(tr!(
+ self.i18n,
+ "Moves this column to another position",
+ "Tooltip for moving a column"
+ ));
}
if move_resp.clicked() {
@@ -513,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,5 +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, Localization};
use notedeck::{NamedFontFamily, NotedeckTextStyle};
use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
@@ -10,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 {
@@ -17,18 +19,17 @@ pub struct ConfigureDeckResponse {
pub name: String,
}
-static CREATE_TEXT: &str = "Create Deck";
-
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: CREATE_TEXT.to_owned(),
+ create_button_text: tr!(i18n, "Create Deck", "Button label to create a new deck"),
+ i18n,
}
}
- pub fn with_create_text(mut self, text: &str) -> Self {
- self.create_button_text = text.to_owned();
+ pub fn with_create_text(mut self, text: String) -> Self {
+ self.create_button_text = text;
self
}
@@ -39,22 +40,34 @@ impl<'a> ConfigureDeckView<'a> {
);
padding(16.0, ui, |ui| {
ui.add(Label::new(
- RichText::new("Deck name").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("We recommend short names")
- .color(ui.visuals().noninteractive().fg_stroke.color)
- .size(notedeck::fonts::get_font_size(
- ui.ctx(),
- &NotedeckTextStyle::Small,
- )),
+ RichText::new(tr!(
+ self.i18n,
+ "We recommend short names",
+ "Hint for deck name input field"
+ ))
+ .color(ui.visuals().noninteractive().fg_stroke.color)
+ .size(notedeck::fonts::get_font_size(
+ ui.ctx(),
+ &NotedeckTextStyle::Small,
+ )),
));
ui.add_space(32.0);
- ui.add(Label::new(RichText::new("Icon").font(title_font)));
+ ui.add(Label::new(
+ RichText::new(tr!(self.i18n, "Icon", "Label for deck icon selection"))
+ .font(title_font),
+ ));
if ui
.add(deck_icon(
@@ -92,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
@@ -120,29 +138,31 @@ impl<'a> ConfigureDeckView<'a> {
}
}
-fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) {
- if warn_no_icon || warn_no_title {
- let messages = [
- if warn_no_title {
- "create a name for the deck"
- } else {
- ""
- },
- if warn_no_icon { "select an icon" } else { "" },
- ];
- let message = messages
- .iter()
- .filter(|&&m| !m.is_empty())
- .copied()
- .collect::<Vec<_>>()
- .join(" and ");
-
- ui.add(
- egui::Label::new(
- RichText::new(format!("Please {message}.")).color(ui.visuals().error_fg_color),
- )
- .wrap(),
- );
+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"
+ )
+ } else {
+ String::new()
+ };
+
+ if !warning.is_empty() {
+ ui.add(egui::Label::new(RichText::new(warning).color(ui.visuals().error_fg_color)).wrap());
}
}
@@ -316,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,22 +3,22 @@ use egui::Widget;
use crate::deck_state::DeckState;
use super::configure_deck::{ConfigureDeckResponse, ConfigureDeckView};
+use notedeck::{tr, Localization};
use notedeck_ui::padding;
pub struct EditDeckView<'a> {
config_view: ConfigureDeckView<'a>,
}
-static EDIT_TEXT: &str = "Edit Deck";
-
pub enum EditDeckResponse {
Edit(ConfigureDeckResponse),
Delete,
}
impl<'a> EditDeckView<'a> {
- pub fn new(state: &'a mut DeckState) -> Self {
- let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT);
+ 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("Delete 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, 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(
- "Send",
+ &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,8 @@ fn show_title(ui: &mut egui::Ui) {
ui.add_space(8.0);
ui.add(egui::Label::new(
- egui::RichText::new("Zap").text_style(NotedeckTextStyle::Heading2.text_style()),
+ egui::RichText::new(tr!(i18n, "Zap", "Heading for zap (tip) action"))
+ .text_style(NotedeckTextStyle::Heading2.text_style()),
));
},
);
@@ -176,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");
@@ -190,7 +199,11 @@ fn show_amount(ui: &mut egui::Ui, id: egui::Id, user_input: &mut String, width:
let painter = ui.painter();
let sats_galley = painter.layout_no_wrap(
- "SATS".to_owned(),
+ tr!(
+ i18n,
+ "SATS",
+ "Label for satoshis (Bitcoin unit) for custom zap amount input field"
+ ),
NotedeckTextStyle::Heading4.get_font_id(ui.ctx()),
ui.visuals().noninteractive().text_color(),
);
@@ -215,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("Amount")
+ egui::RichText::new(tr!(i18n, "Amount", "Label for zap amount input field"))
.text_style(NotedeckTextStyle::Heading3.text_style())
.color(ui.visuals().noninteractive().text_color()),
));
@@ -296,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),
@@ -305,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));
}
},
);
@@ -317,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);
@@ -349,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());
@@ -390,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, "5K"),
- ZapSelectionButton::Fifth => write!(f, "10K"),
- ZapSelectionButton::Sixth => write!(f, "20K"),
- ZapSelectionButton::Seventh => write!(f, "50K"),
- ZapSelectionButton::Eighth => write!(f, "100K"),
+ 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, 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> {
@@ -180,7 +182,14 @@ impl<'a, 'd> PostView<'a, 'd> {
};
let textedit = TextEdit::multiline(&mut self.draft.buffer)
- .hint_text(egui::RichText::new("Write a banger note here...").weak())
+ .hint_text(
+ egui::RichText::new(tr!(
+ self.note_context.i18n,
+ "Write a banger note here...",
+ "Placeholder for note input field"
+ ))
+ .weak(),
+ )
.frame(false)
.desired_width(ui.available_width())
.layouter(&mut layouter);
@@ -405,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| {
@@ -603,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("Post now");
+ let button = egui::Button::new(tr!(i18n, "Post now", "Button label to post a note"));
if interactive {
ui.add(button)
} else {
@@ -798,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, 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
@@ -32,7 +41,18 @@ impl<'a> EditProfileView<'a> {
notedeck_ui::padding(padding, ui, |ui| {
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if ui
- .add(button("Save changes", 119.0).fill(notedeck_ui::colors::PINK))
+ .add(
+ button(
+ tr!(
+ self.i18n,
+ "Save changes",
+ "Button label to save profile changes"
+ )
+ .as_str(),
+ 119.0,
+ )
+ .fill(notedeck_ui::colors::PINK),
+ )
.clicked()
{
save = true;
@@ -62,42 +82,78 @@ impl<'a> EditProfileView<'a> {
);
in_frame(ui, |ui| {
- ui.add(label("Display name"));
+ ui.add(label(
+ 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("Username"));
+ ui.add(label(
+ 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("Profile picture"));
+ ui.add(label(
+ 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("Banner"));
+ ui.add(label(
+ 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("About"));
+ ui.add(label(
+ 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("Website"));
+ ui.add(label(
+ tr!(self.i18n, "Website", "Profile website field label").as_str(),
+ ));
ui.add(singleline_textedit(self.state.str_mut("website")));
});
in_frame(ui, |ui| {
- ui.add(label("Lightning network address (lud16)"));
+ ui.add(label(
+ tr!(
+ self.i18n,
+ "Lightning network address (lud16)",
+ "Bitcoin Lightning network address field label"
+ )
+ .as_str(),
+ ));
ui.add(multiline_textedit(self.state.str_mut("lud16")));
});
in_frame(ui, |ui| {
- ui.add(label("Nostr address (NIP-05 identity)"));
+ ui.add(label(
+ tr!(
+ self.i18n,
+ "Nostr address (NIP-05 identity)",
+ "NIP-05 identity field label"
+ )
+ .as_str(),
+ ));
ui.add(singleline_textedit(self.state.str_mut("nip05")));
let Some(nip05) = self.state.nip05() else {
@@ -121,9 +177,20 @@ impl<'a> EditProfileView<'a> {
ui.colored_label(
ui.visuals().noninteractive().fg_stroke.color,
RichText::new(if use_domain {
- format!("\"{suffix}\" will be used for identification")
+ tr!(
+ self.i18n,
+ "\"{domain}\" will be used for identification",
+ "Domain identification message",
+ domain = suffix
+ )
} else {
- format!("\"{prefix}\" at \"{suffix}\" will be used for identification")
+ tr!(
+ self.i18n,
+ "\"{username}\" at \"{domain}\" will be used for identification",
+ "Username and domain identification message",
+ username = prefix,
+ domain = suffix
+ )
}),
);
});
diff --git a/crates/notedeck_columns/src/ui/profile/mod.rs b/crates/notedeck_columns/src/ui/profile/mod.rs
@@ -4,6 +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, Localization};
use notedeck_ui::profile::follow_button;
use tracing::error;
@@ -90,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
@@ -183,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);
}
}
@@ -333,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);
@@ -362,7 +370,7 @@ fn edit_profile_button() -> impl egui::Widget + 'static {
let edit_icon_size = vec2(16.0, 16.0);
let galley = painter.layout(
- "Edit Profile".to_owned(),
+ 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::{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("Relays")
+ 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
@@ -150,8 +156,12 @@ impl<'a> RelayView<'a> {
let is_enabled = self.pool.is_valid_url(text_buffer);
let text_edit = egui::TextEdit::singleline(text_buffer)
.hint_text(
- RichText::new("Enter the relay here")
- .text_style(NotedeckTextStyle::Body.text_style()),
+ RichText::new(tr!(
+ self.i18n,
+ "Enter the relay here",
+ "Placeholder for relay input field"
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
)
.vertical_align(Align::Center)
.desired_width(f32::INFINITY)
@@ -160,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
@@ -172,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(" Add 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),
@@ -183,9 +196,10 @@ 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 button_widget = styled_button("Add", notedeck_ui::colors::PINK);
+ 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)
}
}
@@ -215,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,
@@ -224,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 => "Connected",
- RelayStatus::Connecting => "Connecting...",
- RelayStatus::Disconnected => "Not Connected",
+ 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()
@@ -286,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::{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,
@@ -119,15 +120,23 @@ impl<'a, 'd> SearchView<'a, 'd> {
note_action = self.show_search_results(ui);
}
SearchState::Searched => {
- ui.label(format!(
- "Got {} results for '{}'",
- self.query.notes.notes.len(),
- &self.query.string
+ ui.label(tr_plural!(
+ self.note_context.i18n,
+ "Got {count} result for '{query}'", // one
+ "Got {count} results for '{query}'", // other
+ "Search results count", // comment
+ self.query.notes.notes.len(), // count
+ query = &self.query.string
));
note_action = self.show_search_results(ui);
}
SearchState::Typing(TypingType::AutoSearch) => {
- ui.label(format!("Searching for '{}'", &self.query.string));
+ ui.label(tr!(
+ self.note_context.i18n,
+ "Searching for '{query}'",
+ "Search in progress message",
+ query = &self.query.string
+ ));
note_action = self.show_search_results(ui);
}
@@ -241,6 +250,7 @@ impl SearchResponse {
}
fn search_box(
+ i18n: &mut Localization,
input: &mut String,
focus_state: FocusState,
ui: &mut egui::Ui,
@@ -282,7 +292,14 @@ fn search_box(
let response = ui.add_sized(
[ui.available_width(), search_height],
TextEdit::singleline(input)
- .hint_text(RichText::new("Search notes...").weak())
+ .hint_text(
+ RichText::new(tr!(
+ i18n,
+ "Search notes...",
+ "Placeholder for search notes input field"
+ ))
+ .weak(),
+ )
//.desired_width(available_width - 32.0)
//.font(egui::FontId::new(font_size, egui::FontFamily::Proportional))
.margin(vec2(0.0, 8.0))
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::{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("DECKS")
- .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::{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) {
@@ -21,15 +22,33 @@ impl<'a> SupportView<'a> {
notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
);
- ui.add(Label::new(RichText::new("Running into a bug?").font(font)));
- ui.label(RichText::new("Step 1").text_style(NotedeckTextStyle::Heading3.text_style()));
+ ui.add(Label::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Running into a bug?",
+ "Heading for support section"
+ ))
+ .font(font),
+ ));
+ ui.label(
+ 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("Open your default email client to get help from the Damus team");
+ ui.label(tr!(
+ self.i18n,
+ "Open your default email client to get help from the Damus team",
+ "Instruction to open email client"
+ ));
let size = vec2(120.0, 40.0);
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!(
@@ -47,16 +66,23 @@ impl<'a> SupportView<'a> {
if let Some(logs) = self.support.get_most_recent_log() {
ui.label(
- RichText::new("Step 2").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("Copy").size(
- notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
- ))
+ let copy_button = Button::new(
+ 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("Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.").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());
@@ -75,8 +101,14 @@ impl<'a> SupportView<'a> {
}
}
-fn open_email_button(font_size: f32, size: egui::Vec2) -> impl egui::Widget {
- Button::new(RichText::new("Open Email").size(font_size))
- .fill(PINK)
- .min_size(size)
+fn open_email_button(
+ i18n: &mut Localization,
+ font_size: f32,
+ size: egui::Vec2,
+) -> impl egui::Widget {
+ Button::new(
+ 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, 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,17 +293,23 @@ 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 => "Notes",
- ViewFilter::NotesAndReplies => "Notes & Replies",
+ ViewFilter::Notes => tr!(i18n, "Notes", "Label for notes-only filter"),
+ ViewFilter::NotesAndReplies => {
+ tr!(
+ i18n,
+ "Notes & Replies",
+ "Label for notes and replies filter"
+ )
+ }
};
- let res = ui.add(egui::Label::new(txt).selectable(false));
+ let res = ui.add(egui::Label::new(txt.clone()).selectable(false));
// underline
if state.is_selected() {
let rect = res.rect;
let underline =
- shrink_range_to_width(rect.x_range(), get_label_width(ui, txt) * 1.15);
+ shrink_range_to_width(rect.x_range(), get_label_width(ui, &txt) * 1.15);
#[allow(deprecated)]
let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5;
return (underline, underline_y);
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, 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,14 +203,19 @@ 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> {
ui.horizontal_wrapped(|ui| 's: {
let text_edit = egui::TextEdit::singleline(&mut state.buf)
.hint_text(
- egui::RichText::new("Paste your NWC URI here...")
- .text_style(notedeck::NotedeckTextStyle::Body.text_style()),
+ egui::RichText::new(tr!(
+ i18n,
+ "Paste your NWC URI here...",
+ "Placeholder text for NWC URI input"
+ ))
+ .text_style(notedeck::NotedeckTextStyle::Body.text_style()),
)
.vertical_align(egui::Align::Center)
.desired_width(f32::INFINITY)
@@ -218,8 +230,16 @@ fn show_no_wallet(
};
let error_str = match error_msg {
- WalletError::InvalidURI => "Invalid NWC URI",
- WalletError::NoWallet => "Add a wallet to continue",
+ 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"
+ ),
};
ui.colored_label(ui.visuals().warn_fg_color, error_str);
});
@@ -229,21 +249,29 @@ fn show_no_wallet(
if show_local_only {
ui.checkbox(
&mut state.for_local_only,
- "Use this wallet for the current account only",
+ tr!(
+ i18n,
+ "Use this wallet for the current account only",
+ "Checkbox label for using wallet only for current account"
+ ),
);
ui.add_space(8.0);
}
ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
- ui.add(styled_button("Add Wallet", notedeck_ui::colors::PINK))
- .clicked()
- .then_some(WalletAction::SaveURI)
+ ui.add(styled_button(
+ tr!(i18n, "Add Wallet", "Button label to add a wallet").as_str(),
+ notedeck_ui::colors::PINK,
+ ))
+ .clicked()
+ .then_some(WalletAction::SaveURI)
})
.inner
}
fn show_with_wallet(
ui: &mut egui::Ui,
+ i18n: &mut Localization,
wallet: &mut Wallet,
default_zap_state: &mut DefaultZapState,
can_create_local_wallet: bool,
@@ -264,11 +292,14 @@ 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("Delete Wallet", ui.visuals().window_fill))
+ .add(styled_button(
+ tr!(i18n, "Delete Wallet", "Button label to delete a wallet").as_str(),
+ ui.visuals().window_fill,
+ ))
.clicked()
{
action = Some(WalletAction::Delete);
@@ -280,7 +311,11 @@ fn show_with_wallet(
&& ui
.checkbox(
&mut false,
- "Add a different wallet that will only be used for this account",
+ tr!(
+ i18n,
+ "Add a different wallet that will only be used for this account",
+ "Button label to add a different wallet"
+ ),
)
.clicked()
{
@@ -302,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("Default amount per zap: ");
+ 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;
@@ -340,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(" sats");
+ ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));
if ui
- .add(styled_button("Save", 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(" sats");
+ 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 => "Invalid amount",
+ notedeck::DefaultZapError::InvalidUserInput => tr!(i18n, "Invalid amount", "Error message for invalid zap amount"),
};
ui.colored_label(ui.visuals().warn_fg_color, msg_str);
@@ -372,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();
@@ -388,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("Click to edit");
+ .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::{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(
- "The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!",
+ 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,12 +304,19 @@ 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("Ask")).clicked() {
+ if ui
+ .add(egui::Button::new(tr!(
+ i18n,
+ "Ask",
+ "Button to send message to Dave AI assistant"
+ )))
+ .clicked()
+ {
dave_response = DaveResponse::send();
}
@@ -322,7 +330,14 @@ impl<'a> DaveUi<'a> {
},
Key::Enter,
))
- .hint_text(egui::RichText::new("Ask dave anything...").weak())
+ .hint_text(
+ egui::RichText::new(tr!(
+ i18n,
+ "Ask dave anything...",
+ "Placeholder text for Dave AI input field"
+ ))
+ .weak(),
+ )
.frame(false),
);
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::{BroadcastContext, NoteContextSelection};
+use notedeck::{tr, BroadcastContext, Localization, NoteContextSelection};
pub struct NoteContextButton {
put_at: Option<Rect>,
@@ -105,35 +105,80 @@ 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;
stationary_arbitrary_menu_button(ui, button_response, |ui| {
ui.set_max_width(200.0);
- if ui.button("Copy text").clicked() {
+
+ // Debug: Check what the tr! macro returns
+ let copy_text = tr!(
+ i18n,
+ "Copy Text",
+ "Copy the text content of the note to clipboard"
+ );
+ tracing::debug!("Copy Text translation: '{}'", copy_text);
+
+ if ui.button(copy_text).clicked() {
context_selection = Some(NoteContextSelection::CopyText);
ui.close_menu();
}
- if ui.button("Copy user public key").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Copy Pubkey",
+ "Copy the author's public key to clipboard"
+ ))
+ .clicked()
+ {
context_selection = Some(NoteContextSelection::CopyPubkey);
ui.close_menu();
}
- if ui.button("Copy note id").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Copy Note ID",
+ "Copy the unique note identifier to clipboard"
+ ))
+ .clicked()
+ {
context_selection = Some(NoteContextSelection::CopyNoteId);
ui.close_menu();
}
- if ui.button("Copy note json").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Copy Note JSON",
+ "Copy the raw note data in JSON format to clipboard"
+ ))
+ .clicked()
+ {
context_selection = Some(NoteContextSelection::CopyNoteJSON);
ui.close_menu();
}
- if ui.button("Broadcast").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Broadcast",
+ "Broadcast the note to all connected relays"
+ ))
+ .clicked()
+ {
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::Everywhere,
));
ui.close_menu();
}
- if ui.button("Broadcast to local network").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Broadcast Local",
+ "Broadcast the note only to local network relays"
+ ))
+ .clicked()
+ {
context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::LocalNetwork,
));
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,
- 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,12 +649,19 @@ 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("Copy Link").clicked() {
+ if ui
+ .button(tr!(
+ i18n,
+ "Copy Link",
+ "Button to copy media link to clipboard"
+ ))
+ .clicked()
+ {
ui.ctx().copy_text(url.to_owned());
ui.close_menu();
}
@@ -650,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
@@ -692,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
@@ -709,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());
@@ -722,14 +753,19 @@ fn render_blur_text(ui: &mut egui::Ui, url: &str, render_rect: egui::Rect) -> eg
text_style.font_family(),
);
let info_galley = painter.layout(
- "Media from someone you don't follow".to_owned(),
+ tr!(
+ i18n,
+ "Media from someone you don't follow",
+ "Text shown on blurred media from unfollowed users"
+ )
+ .to_owned(),
animation_fontid.clone(),
ui.visuals().text_color(),
render_rect.width() / 2.0,
);
let load_galley = painter.layout_no_wrap(
- "Tap to Load".to_owned(),
+ tr!(i18n, "Tap to Load", "Button text to load blurred media"),
animation_fontid,
egui::Color32::BLACK,
// ui.visuals().widgets.inactive.bg_fill,
@@ -785,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 {
@@ -876,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},
- 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("Reposted")
- .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, ¬e_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("Reply to this note");
+ 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("Repost this note");
+ .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,7 +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("Zap this note");
+ 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
@@ -1,177 +1,318 @@
use egui::{Label, RichText, Sense};
-use nostrdb::{Note, NoteReply, Transaction};
+use nostrdb::{NoteReply, Transaction};
use super::NoteOptions;
use crate::{jobs::JobsCache, note::NoteView, Mention};
-use notedeck::{NoteAction, NoteContext};
+use notedeck::{tr, NoteAction, NoteContext};
-#[must_use = "Please handle the resulting note action"]
-#[profiling::function]
-pub fn reply_desc(
+// Rich text segment types for internationalized rendering
+#[derive(Debug, Clone)]
+pub enum TextSegment {
+ Plain(String),
+ UserMention([u8; 32]), // pubkey
+ ThreadUserMention([u8; 32]), // pubkey
+ NoteLink([u8; 32]),
+ ThreadLink([u8; 32]),
+}
+
+// Helper function to parse i18n template strings with placeholders
+fn parse_i18n_template(template: &str) -> Vec<TextSegment> {
+ let mut segments = Vec::new();
+ let mut current_text = String::new();
+ let mut chars = template.chars().peekable();
+
+ while let Some(ch) = chars.next() {
+ if ch == '{' {
+ // Save any accumulated plain text
+ if !current_text.is_empty() {
+ segments.push(TextSegment::Plain(current_text.clone()));
+ current_text.clear();
+ }
+
+ // Parse placeholder
+ let mut placeholder = String::new();
+ for ch in chars.by_ref() {
+ if ch == '}' {
+ break;
+ }
+ placeholder.push(ch);
+ }
+
+ // Handle different placeholder types
+ match placeholder.as_str() {
+ // Placeholder values will be filled later.
+ "user" => segments.push(TextSegment::UserMention([0; 32])),
+ "thread_user" => segments.push(TextSegment::ThreadUserMention([0; 32])),
+ "note" => segments.push(TextSegment::NoteLink([0; 32])),
+ "thread" => segments.push(TextSegment::ThreadLink([0; 32])),
+ _ => {
+ // Unknown placeholder, treat as plain text
+ current_text.push_str(&format!("{{{placeholder}}}"));
+ }
+ }
+ } else {
+ current_text.push(ch);
+ }
+ }
+
+ // Add any remaining plain text
+ if !current_text.is_empty() {
+ segments.push(TextSegment::Plain(current_text));
+ }
+
+ segments
+}
+
+// Helper function to fill in the actual data for placeholders
+fn fill_template_data(
+ mut segments: Vec<TextSegment>,
+ reply_pubkey: &[u8; 32],
+ reply_note_id: &[u8; 32],
+ root_pubkey: Option<&[u8; 32]>,
+ root_note_id: Option<&[u8; 32]>,
+) -> Vec<TextSegment> {
+ for segment in &mut segments {
+ match segment {
+ TextSegment::UserMention(pubkey) if *pubkey == [0; 32] => {
+ *pubkey = *reply_pubkey;
+ }
+ TextSegment::ThreadUserMention(pubkey) if *pubkey == [0; 32] => {
+ *pubkey = *root_pubkey.unwrap_or(reply_pubkey);
+ }
+ TextSegment::NoteLink(note_id) if *note_id == [0; 32] => {
+ *note_id = *reply_note_id;
+ }
+ TextSegment::ThreadLink(note_id) if *note_id == [0; 32] => {
+ *note_id = *root_note_id.unwrap_or(reply_note_id);
+ }
+ _ => {}
+ }
+ }
+
+ segments
+}
+
+// Main rendering function for text segments
+#[allow(clippy::too_many_arguments)]
+fn render_text_segments(
ui: &mut egui::Ui,
+ segments: &[TextSegment],
txn: &Transaction,
- note_reply: &NoteReply,
note_context: &mut NoteContext,
note_options: NoteOptions,
jobs: &mut JobsCache,
+ size: f32,
+ selectable: bool,
) -> Option<NoteAction> {
let mut note_action: Option<NoteAction> = None;
- let size = 10.0;
- let selectable = false;
let visuals = ui.visuals();
let color = visuals.noninteractive().fg_stroke.color;
let link_color = visuals.hyperlink_color;
- // note link renderer helper
- let note_link = |ui: &mut egui::Ui,
- note_context: &mut NoteContext,
- text: &str,
- note: &Note<'_>,
- jobs: &mut JobsCache| {
- let r = ui.add(
- Label::new(RichText::new(text).size(size).color(link_color))
- .sense(Sense::click())
- .selectable(selectable),
- );
+ for segment in segments {
+ match segment {
+ TextSegment::Plain(text) => {
+ ui.add(
+ Label::new(RichText::new(text).size(size).color(color)).selectable(selectable),
+ );
+ }
+ TextSegment::UserMention(pubkey) | TextSegment::ThreadUserMention(pubkey) => {
+ let action = Mention::new(note_context.ndb, note_context.img_cache, txn, pubkey)
+ .size(size)
+ .selectable(selectable)
+ .show(ui);
- if r.clicked() {
- // TODO: jump to note
- }
+ if action.is_some() {
+ note_action = action;
+ }
+ }
+ TextSegment::NoteLink(note_id) => {
+ if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) {
+ let r = ui.add(
+ Label::new(
+ RichText::new(tr!(
+ note_context.i18n,
+ "note",
+ "Link text for note references"
+ ))
+ .size(size)
+ .color(link_color),
+ )
+ .sense(Sense::click())
+ .selectable(selectable),
+ );
- if r.hovered() {
- r.on_hover_ui_at_pointer(|ui| {
- ui.set_max_width(400.0);
- NoteView::new(note_context, note, note_options, jobs)
- .actionbar(false)
- .wide(true)
- .show(ui);
- });
+ if r.clicked() {
+ // TODO: jump to note
+ }
+
+ if r.hovered() {
+ r.on_hover_ui_at_pointer(|ui| {
+ ui.set_max_width(400.0);
+ NoteView::new(note_context, ¬e, note_options, jobs)
+ .actionbar(false)
+ .wide(true)
+ .show(ui);
+ });
+ }
+ }
+ }
+ TextSegment::ThreadLink(note_id) => {
+ if let Ok(note) = note_context.ndb.get_note_by_id(txn, note_id) {
+ let r = ui.add(
+ Label::new(
+ RichText::new(tr!(
+ note_context.i18n,
+ "thread",
+ "Link text for thread references"
+ ))
+ .size(size)
+ .color(link_color),
+ )
+ .sense(Sense::click())
+ .selectable(selectable),
+ );
+
+ if r.clicked() {
+ // TODO: jump to note
+ }
+
+ if r.hovered() {
+ r.on_hover_ui_at_pointer(|ui| {
+ ui.set_max_width(400.0);
+ NoteView::new(note_context, ¬e, note_options, jobs)
+ .actionbar(false)
+ .wide(true)
+ .show(ui);
+ });
+ }
+ }
+ }
}
- };
+ }
- ui.add(Label::new(RichText::new("replying to").size(size).color(color)).selectable(selectable));
+ note_action
+}
+
+#[must_use = "Please handle the resulting note action"]
+#[profiling::function]
+pub fn reply_desc(
+ ui: &mut egui::Ui,
+ txn: &Transaction,
+ note_reply: &NoteReply,
+ note_context: &mut NoteContext,
+ note_options: NoteOptions,
+ jobs: &mut JobsCache,
+) -> Option<NoteAction> {
+ let size = 10.0;
+ let selectable = false;
let reply = note_reply.reply()?;
let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) {
reply_note
} else {
- ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable));
- return None;
+ // 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"
+ );
+ let segments = parse_i18n_template(&template);
+ return render_text_segments(
+ ui,
+ &segments,
+ txn,
+ note_context,
+ note_options,
+ jobs,
+ size,
+ selectable,
+ );
};
- if note_reply.is_reply_to_root() {
- // We're replying to the root, let's show this
- let action = Mention::new(
- note_context.ndb,
- note_context.img_cache,
- txn,
+ 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}",
+ thread = "{thread}"
+ );
+ let segments = parse_i18n_template(&template);
+ fill_template_data(
+ segments,
reply_note.pubkey(),
+ reply.id,
+ None,
+ Some(reply.id),
)
- .size(size)
- .selectable(selectable)
- .show(ui);
-
- if action.is_some() {
- note_action = action;
- }
-
- ui.add(Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable));
-
- note_link(ui, note_context, "thread", &reply_note, jobs);
} else if let Some(root) = note_reply.root() {
- // replying to another post in a thread, not the root
-
if let Ok(root_note) = note_context.ndb.get_note_by_id(txn, root.id) {
if root_note.pubkey() == reply_note.pubkey() {
- // simply "replying to bob's note" when replying to bob in his thread
- let action = Mention::new(
- note_context.ndb,
- note_context.img_cache,
- txn,
- reply_note.pubkey(),
- )
- .size(size)
- .selectable(selectable)
- .show(ui);
-
- if action.is_some() {
- note_action = action;
- }
-
- ui.add(
- Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
+ // 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}",
+ note = "{note}"
);
-
- note_link(ui, note_context, "note", &reply_note, jobs);
+ let segments = parse_i18n_template(&template);
+ fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
} else {
- // replying to bob in alice's thread
-
- let action = Mention::new(
- note_context.ndb,
- note_context.img_cache,
- txn,
- reply_note.pubkey(),
- )
- .size(size)
- .selectable(selectable)
- .show(ui);
-
- if action.is_some() {
- note_action = action;
- }
-
- ui.add(
- Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
+ // 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}",
+ note = "{note}",
+ thread_user = "{thread_user}",
+ thread = "{thread}"
);
-
- note_link(ui, note_context, "note", &reply_note, jobs);
-
- ui.add(
- Label::new(RichText::new("in").size(size).color(color)).selectable(selectable),
- );
-
- let action = Mention::new(
- note_context.ndb,
- note_context.img_cache,
- txn,
- root_note.pubkey(),
+ let segments = parse_i18n_template(&template);
+ fill_template_data(
+ segments,
+ reply_note.pubkey(),
+ reply.id,
+ Some(root_note.pubkey()),
+ Some(root.id),
)
- .size(size)
- .selectable(selectable)
- .show(ui);
-
- if action.is_some() {
- note_action = action;
- }
-
- ui.add(
- Label::new(RichText::new("'s").size(size).color(color)).selectable(selectable),
- );
-
- note_link(ui, note_context, "thread", &root_note, jobs);
}
} else {
- let action = Mention::new(
- note_context.ndb,
- note_context.img_cache,
- txn,
- reply_note.pubkey(),
- )
- .size(size)
- .selectable(selectable)
- .show(ui);
-
- if action.is_some() {
- note_action = action;
- }
-
- ui.add(
- Label::new(RichText::new("in someone's thread").size(size).color(color))
- .selectable(selectable),
+ // 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}"
);
+ let segments = parse_i18n_template(&template);
+ fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
}
- }
+ } else {
+ // Fallback
+ let template = tr!(
+ note_context.i18n,
+ "replying to {user}",
+ "Fallback template for replying to user",
+ user = "{user}"
+ );
+ let segments = parse_i18n_template(&template);
+ fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
+ };
- note_action
+ render_text_segments(
+ ui,
+ &segments,
+ txn,
+ note_context,
+ note_options,
+ jobs,
+ size,
+ selectable,
+ )
}
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, 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("Read only")
- .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;
+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,
@@ -52,7 +58,12 @@ impl Widget for Username<'_> {
}
}
} else {
- let mut txt = RichText::new("nostrich").family(NamedFontFamily::Medium.as_family());
+ let mut txt = RichText::new(tr!(
+ self.i18n,
+ "nostrich",
+ "Default username when profile is not available"
+ ))
+ .family(NamedFontFamily::Medium.as_family());
if let Some(col) = color {
txt = txt.color(col)
}
diff --git a/scripts/export_source_strings.py b/scripts/export_source_strings.py
@@ -0,0 +1,595 @@
+#!/usr/bin/env python3
+"""
+Export US English (en-US) strings defined in tr! and tr_plural! macros in Rust code
+by generating a main.ftl file that can be used for translating into other languages.
+
+This script also creates a Psuedolocalized English (en-XA) main.ftl file with a given number of characters accented,
+so that developers can easily detect which strings have been internationalized or not without needing to have
+actual translations for a non-English language instead.
+"""
+
+import os
+import re
+import argparse
+from pathlib import Path
+from typing import Set, Dict, List, Tuple
+import json
+import collections
+import hashlib
+
+def find_rust_files(project_root: Path) -> List[Path]:
+ """Find all Rust files in the project."""
+ rust_files = []
+ for root, dirs, files in os.walk(project_root):
+ # Skip irrelevant directories
+ dirs[:] = [d for d in dirs if d not in ['target', '.git', '.cargo']]
+
+ for file in files:
+ # Find only Rust source files
+ if file.endswith('.rs'):
+ rust_files.append(Path(root) / file)
+
+ return rust_files
+
+def strip_rust_comments(code: str) -> str:
+ """Remove // line comments, /* ... */ block comments, and doc comments (///, //!, //! ...) from Rust code."""
+ # Remove block comments first
+ code = re.sub(r'/\*.*?\*/', '', code, flags=re.DOTALL)
+ # Remove line comments
+ code = re.sub(r'//.*', '', code)
+ # Remove doc comments (/// and //! at start of line)
+ code = re.sub(r'^\s*///.*$', '', code, flags=re.MULTILINE)
+ code = re.sub(r'^\s*//!.*$', '', code, flags=re.MULTILINE)
+ return code
+
+def extract_tr_macros_with_lines(content: str, file_path: str) -> dict:
+ """Extract tr! macro calls from Rust code with comments and line numbers. Handles multi-line macros."""
+ matches = []
+ # Strip comments before processing
+ content = strip_rust_comments(content)
+ # Search the entire content for tr! macro calls (multi-line aware)
+ for macro_content in extract_macro_calls(content, 'tr!'):
+ args = parse_macro_arguments(macro_content)
+ if len(args) >= 3: # Must have at least message and comment
+ message = args[1].strip()
+ comment = args[2].strip() # Second argument is always the comment
+ # Validate placeholders
+ if not validate_placeholders(message, file_path):
+ continue
+ if not any(skip in message.lower() for skip in [
+ '/', '\\', '.ftl', '.rs', 'http', 'https', 'www', '@',
+ 'crates/', 'src/', 'target/', 'build.rs']):
+ # Find the line number where this macro starts
+ macro_start = f'tr!({macro_content}'
+ idx = content.find(macro_start)
+ line_num = content[:idx].count('\n') + 1 if idx != -1 else 1
+ matches.append((message, comment, line_num, file_path))
+ return matches
+
+def extract_tr_plural_macros_with_lines(content: str, file_path: str) -> dict:
+ """Extract tr_plural! macro calls from Rust code with new signature and correct keying, skipping macro definitions and doc comments."""
+ matches = []
+ # Skip macro definitions
+ if 'macro_rules! tr_plural' in content or file_path.endswith('i18n/mod.rs'):
+ return matches
+ for idx, macro_content in enumerate(extract_macro_calls(content, 'tr_plural!')):
+ args = parse_macro_arguments(macro_content)
+ if len(args) >= 5:
+ one = args[1].strip()
+ other = args[2].strip()
+ comment = args[3].strip()
+ key = other
+ if key and not key.startswith('//') and not key.startswith('$'):
+ matches.append((key, comment, idx + 1, file_path))
+ return matches
+
+def parse_macro_arguments(content: str) -> List[str]:
+ """Parse macro arguments, handling quoted strings, param = value pairs, commas, and inline comments."""
+ # Remove all // comments
+ content = re.sub(r'//.*', '', content)
+ # Collapse all whitespace/newlines to a single space
+ content = re.sub(r'\s+', ' ', content.strip())
+ args = []
+ i = 0
+ n = len(content)
+ while i < n:
+ # Skip whitespace
+ while i < n and content[i].isspace():
+ i += 1
+ if i >= n:
+ break
+ # Handle quoted strings
+ if content[i] in ['"', "'"]:
+ quote_char = content[i]
+ i += 1
+ arg_start = i
+ while i < n:
+ if content[i] == '\\' and i + 1 < n:
+ i += 2
+ elif content[i] == quote_char:
+ break
+ else:
+ i += 1
+ arg = content[arg_start:i]
+ args.append(arg)
+ i += 1 # Skip closing quote
+ else:
+ arg_start = i
+ paren_count = 0
+ brace_count = 0
+ while i < n:
+ char = content[i]
+ if char == '(':
+ paren_count += 1
+ elif char == ')':
+ paren_count -= 1
+ elif char == '{':
+ brace_count += 1
+ elif char == '}':
+ brace_count -= 1
+ elif char == ',' and paren_count == 0 and brace_count == 0:
+ break
+ i += 1
+ arg = content[arg_start:i].strip()
+ if arg:
+ args.append(arg)
+ # Skip the comma if we found one
+ if i < n and content[i] == ',':
+ i += 1
+ return args
+
+def extract_macro_calls(content: str, macro_name: str):
+ """Extract all macro calls of the given macro_name from the entire content, handling parentheses inside quoted strings and multi-line macros."""
+ calls = []
+ idx = 0
+ macro_start = f'{macro_name}('
+ content_len = len(content)
+ while idx < content_len:
+ start = content.find(macro_start, idx)
+ if start == -1:
+ break
+ i = start + len(macro_start)
+ paren_count = 1 # Start after the initial '('
+ in_quote = False
+ quote_char = ''
+ macro_content = ''
+ while i < content_len:
+ c = content[i]
+ if in_quote:
+ macro_content += c
+ if c == quote_char and (i == 0 or content[i-1] != '\\'):
+ in_quote = False
+ else:
+ if c in ('"', "'"):
+ in_quote = True
+ quote_char = c
+ macro_content += c
+ elif c == '(':
+ paren_count += 1
+ macro_content += c
+ elif c == ')':
+ paren_count -= 1
+ if paren_count == 0:
+ break
+ else:
+ macro_content += c
+ else:
+ macro_content += c
+ i += 1
+ # Only add if we found a closing parenthesis
+ if i < content_len and content[i] == ')':
+ calls.append(macro_content)
+ idx = i + 1
+ else:
+ # Malformed macro, skip past this occurrence
+ idx = start + len(macro_start)
+ return calls
+
+def validate_placeholders(message: str, file_path: str = "") -> bool:
+ """Validate that all placeholders in a message are named and start with a letter."""
+ import re
+
+ # Find all placeholders in the message
+ placeholder_pattern = r'\{([^}]*)\}'
+ placeholders = re.findall(placeholder_pattern, message)
+
+ valid = True
+ for placeholder in placeholders:
+ if not placeholder.strip():
+ print(f"[VALIDATE] Warning: Empty placeholder {{}} found in message: '{message[:100]}...' {file_path}")
+ valid = False
+ elif not placeholder[0].isalpha():
+ print(f"[VALIDATE] Warning: Placeholder '{{{placeholder}}}' does not start with a letter in message: '{message[:100]}...' {file_path}")
+ valid = False
+ if not valid:
+ print(f"[VALIDATE] Message rejected: '{message}'")
+ return valid
+
+def extract_tr_macros(content: str) -> List[Tuple[str, str]]:
+ """Extract tr! macro calls from Rust code with comments."""
+ filtered_matches = []
+ # Strip comments before processing
+ content = strip_rust_comments(content)
+ # Process the entire content instead of line by line to handle multi-line macros
+ for macro_content in extract_macro_calls(content, 'tr!'):
+ args = parse_macro_arguments(macro_content)
+ if len(args) >= 3: # Must have at least message and comment
+ message = args[1].strip()
+ comment = args[2].strip() # Second argument is always the comment
+ # Debug output for identification strings
+ if "identification" in comment.lower():
+ print(f"[DEBUG] Found identification tr! macro: message='{message}', comment='{comment}', args={args}")
+ norm_key = normalize_key(message, comment)
+ print(f"[DEBUG] Normalized key: '{norm_key}'")
+ # Validate placeholders
+ if not validate_placeholders(message):
+ continue
+ # More specific filtering logic
+ should_skip = False
+ for skip in ['/', '.ftl', '.rs', 'http', 'https', 'www', 'crates/', 'src/', 'target/', 'build.rs']:
+ if skip in message.lower():
+ should_skip = True
+ break
+ # Special handling for @ - only skip if it looks like an actual email address
+ if '@' in message and (
+ # Skip if it's a short string that looks like an email
+ len(message) < 50 or
+ # Skip if it contains common email patterns
+ any(pattern in message.lower() for pattern in ['@gmail.com', '@yahoo.com', '@hotmail.com', '@outlook.com'])
+ ):
+ should_skip = True
+ if not should_skip:
+ # Store as (message, comment) tuple to preserve all combinations
+ filtered_matches.append((message, comment))
+ return filtered_matches
+
+def extract_tr_plural_macros(content: str, file_path: str = "") -> Dict[str, dict]:
+ """Extract tr_plural! macro calls from Rust code with new signature, skipping macro definitions and doc comments."""
+ filtered_matches = {}
+ # Skip macro definitions
+ if 'macro_rules! tr_plural' in content or file_path.endswith('i18n/mod.rs'):
+ print(f"[DEBUG] Skipping macro definitions in {file_path}")
+ return filtered_matches
+ for macro_content in extract_macro_calls(content, 'tr_plural!'):
+ print(f"[DEBUG] Found tr_plural! macro in {file_path}: {macro_content}")
+ args = parse_macro_arguments(macro_content)
+ print(f"[DEBUG] Parsed args: {args}")
+ if len(args) >= 5:
+ one = args[1].strip()
+ other = args[2].strip()
+ comment = args[3].strip()
+ key = other
+ if key and not key.startswith('//') and not key.startswith('$'):
+ print(f"[DEBUG] Adding plural key '{key}' from {file_path}")
+ filtered_matches[key] = {
+ 'one': one,
+ 'other': other,
+ 'comment': comment
+ }
+ return filtered_matches
+
+def escape_rust_placeholders(text: str) -> str:
+ """Convert Rust-style placeholders to Fluent-style placeholders"""
+ # Unescape double quotes first
+ text = text.replace('\\"', '"')
+ # Convert Rust placeholders to Fluent placeholders
+ return re.sub(r'\{([a-zA-Z][a-zA-Z0-9_]*)\}', r'{$\1}', text)
+
+def simple_hash(s: str) -> str:
+ """Simple hash function using MD5 - matches Rust implementation, 4 hex chars"""
+ return hashlib.md5(s.encode('utf-8')).hexdigest()[:4]
+
+def normalize_key(message, comment=None):
+ """Normalize a message to create a consistent key - matches Rust normalize_ftl_key function"""
+ # Remove quotes and normalize
+ key = message.strip('"\'')
+ # Unescape double quotes
+ key = key.replace('\\"', '"')
+ # Replace each invalid character with exactly one underscore (allow hyphens and underscores)
+ key = re.sub(r'[^a-zA-Z0-9_-]', '_', key)
+ # Remove leading/trailing underscores
+ key = key.strip('_')
+ # Add 'k_' prefix if the result doesn't start with a letter (Fluent requirement)
+ if not (key and key[0].isalpha()):
+ key = "k_" + key
+
+ # If we have a comment, append a hash of it to reduce collisions
+ if comment:
+ # Create a hash of the comment and append it to the key
+ hash_str = f"_{simple_hash(comment)}"
+ key += hash_str
+
+ return key
+
+def pseudolocalize(text: str) -> str:
+ """Convert English text to pseudolocalized text for testing."""
+ # Common pseudolocalization patterns
+ replacements = {
+ 'a': 'à', 'e': 'é', 'i': 'í', 'o': 'ó', 'u': 'ú',
+ 'A': 'À', 'E': 'É', 'I': 'Í', 'O': 'Ó', 'U': 'Ú',
+ 'n': 'ñ', 'N': 'Ñ', 'c': 'ç', 'C': 'Ç'
+ }
+
+ # First, protect Fluent placeables from pseudolocalization
+ placeable_pattern = r'\{ *\$[a-zA-Z][a-zA-Z0-9_]* *\}'
+ placeables = re.findall(placeable_pattern, text)
+
+ # Replace placeables with unique placeholders that won't be modified
+ protected_text = text
+ for i, placeable in enumerate(placeables):
+ placeholder = f"<<PLACEABLE_{i}>>"
+ protected_text = protected_text.replace(placeable, placeholder, 1)
+
+ # Apply character replacements, skipping <<PLACEABLE_n>>
+ result = ''
+ i = 0
+ while i < len(protected_text):
+ if protected_text.startswith('<<PLACEABLE_', i):
+ end = protected_text.find('>>', i)
+ if end != -1:
+ result += protected_text[i:end+2]
+ i = end + 2
+ continue
+ char = protected_text[i]
+ result += replacements.get(char, char)
+ i += 1
+
+ # Restore placeables
+ for i, placeable in enumerate(placeables):
+ placeholder = f"<<PLACEABLE_{i}>>"
+ result = result.replace(placeholder, placeable)
+
+ # Wrap pseudolocalized string with square brackets so that it can be distinguished from other strings
+ return f'{{"["}}{result}{{"]"}}'
+
+def generate_ftl_content(tr_strings: Dict[str, str],
+ plural_strings: Dict[str, dict],
+ tr_occurrences: Dict[Tuple[str, str], list],
+ plural_occurrences: Dict[Tuple[str, str], list],
+ pseudolocalize_content: bool = False) -> str:
+ """Generate FTL file content from extracted strings with comments."""
+
+ lines = [
+ "# Main translation file for Notedeck",
+ "# This file contains common UI strings used throughout the application",
+ "# Auto-generated by extract_i18n.py - DO NOT EDIT MANUALLY",
+ "",
+ ]
+
+ # Sort strings for consistent output
+ sorted_tr = sorted(tr_strings.items(), key=lambda item: item[0].lower())
+ sorted_plural = sorted(plural_strings.items(), key=lambda item: item[0].lower())
+
+ # Add regular tr! strings
+ if sorted_tr:
+ lines.append("# Regular strings")
+ for norm_key, (original_message, comment) in sorted_tr:
+ lines.append("")
+ # Write the comment
+ if comment:
+ lines.append(f"# {comment}")
+ # Apply pseudolocalization if requested
+ value = escape_rust_placeholders(original_message)
+ value = pseudolocalize(value) if pseudolocalize_content else value
+ lines.append(f"{norm_key} = {value}")
+ lines.append("")
+
+ # Add pluralized strings
+ if sorted_plural:
+ lines.append("# Pluralized strings")
+ for key, data in sorted_plural:
+ lines.append("")
+
+ one = data['one']
+ other = data['other']
+ comment = data['comment']
+ # Write comment
+ if comment:
+ lines.append(f"# {comment}")
+ norm_key = normalize_key(key, comment)
+ one_val = escape_rust_placeholders(one)
+ other_val = escape_rust_placeholders(other)
+ if pseudolocalize_content:
+ one_val = pseudolocalize(one_val)
+ other_val = pseudolocalize(other_val)
+ lines.append(f'{norm_key} =')
+ lines.append(f' {{ $count ->')
+ lines.append(f' [one] {one_val}')
+ lines.append(f' *[other] {other_val}')
+ lines.append(f' }}')
+ lines.append("")
+
+ return "\n".join(lines)
+
+def read_existing_ftl(ftl_path: Path) -> Dict[str, str]:
+ """Read existing FTL file to preserve comments and custom translations."""
+ if not ftl_path.exists():
+ return {}
+
+ existing_translations = {}
+ with open(ftl_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Extract key-value pairs
+ pattern = r'^([^#\s][^=]*?)\s*=\s*(.+)$'
+ for line in content.split('\n'):
+ match = re.match(pattern, line.strip())
+ if match:
+ key = match.group(1).strip()
+ value = match.group(2).strip()
+ # For existing FTL files, we need to handle keys that may have hash suffixes
+ # Strip the hash suffix if present (8 hex characters after underscore)
+ original_key = re.sub(r'_[0-9a-f]{8}$', '', key)
+ norm_key = normalize_key(original_key)
+ existing_translations[norm_key] = value
+
+ return existing_translations
+
+def main():
+ parser = argparse.ArgumentParser(description='Extract i18n macros and generate FTL file')
+ parser.add_argument('--project-root', type=str, default='.',
+ help='Project root directory (default: current directory)')
+ parser.add_argument('--dry-run', action='store_true',
+ help='Show what would be generated without writing to file')
+ parser.add_argument('--fail-on-collisions', action='store_true',
+ help='Exit with error if key collisions are detected')
+
+ args = parser.parse_args()
+
+ project_root = Path(args.project_root)
+
+ print(f"Scanning Rust files in {project_root}...")
+
+ # Find all Rust files
+ rust_files = find_rust_files(project_root)
+ print(f"Found {len(rust_files)} Rust files")
+
+ # Extract strings from all files
+ all_tr_strings = {}
+ all_plural_strings = {}
+
+ # Track normalized keys to detect actual key collisions
+ all_tr_normalized_keys = {}
+ all_plural_normalized_keys = {}
+
+ # Track collisions
+ tr_collisions = {}
+ plural_collisions = {}
+
+ # Track all occurrences for intra-file collision detection
+ tr_occurrences = collections.defaultdict(list)
+ plural_occurrences = collections.defaultdict(list)
+
+ for rust_file in rust_files:
+ try:
+ with open(rust_file, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # For intra-file collision detection
+ tr_lines = extract_tr_macros_with_lines(content, str(rust_file))
+ for key, comment, line, file_path in tr_lines:
+ tr_occurrences[(file_path, key)].append((comment, line))
+ plural_lines = extract_tr_plural_macros_with_lines(content, str(rust_file))
+ for key, comment, line, file_path in plural_lines:
+ plural_occurrences[(file_path, key)].append((comment, line))
+
+ tr_strings = extract_tr_macros(content)
+ plural_strings = extract_tr_plural_macros(content, str(rust_file))
+
+ if tr_strings or plural_strings:
+ print(f" {rust_file}: {len(tr_strings)} tr!, {len(plural_strings)} tr_plural!")
+
+ # Check for collisions in tr! strings using normalized keys
+ for message, comment in tr_strings:
+ norm_key = normalize_key(message, comment)
+ if norm_key in all_tr_normalized_keys:
+ # This is a real key collision (same normalized key)
+ if norm_key not in tr_collisions:
+ tr_collisions[norm_key] = []
+ tr_collisions[norm_key].append((rust_file, all_tr_normalized_keys[norm_key]))
+ tr_collisions[norm_key].append((rust_file, comment))
+ # Store by normalized key to preserve all unique combinations
+ all_tr_strings[norm_key] = (message, comment)
+ all_tr_normalized_keys[norm_key] = comment
+
+ # Check for collisions in plural strings using normalized keys
+ for key, data in plural_strings.items():
+ comment = data['comment']
+ norm_key = normalize_key(key, comment)
+ if norm_key in all_plural_normalized_keys:
+ # This is a real key collision (same normalized key)
+ if norm_key not in plural_collisions:
+ plural_collisions[norm_key] = []
+ plural_collisions[norm_key].append((rust_file, all_plural_normalized_keys[norm_key]))
+ plural_collisions[norm_key].append((rust_file, data))
+ all_plural_strings[key] = data
+ all_plural_normalized_keys[norm_key] = data
+
+ except Exception as e:
+ print(f"Error reading {rust_file}: {e}")
+
+ # Intra-file collision detection
+ has_intra_file_collisions = False
+ for (file_path, key), occurrences in tr_occurrences.items():
+ comments = set(c for c, _ in occurrences)
+ if len(occurrences) > 1 and len(comments) > 1:
+ has_intra_file_collisions = True
+ print(f"\n⚠️ Intra-file key collision in {file_path} for '{key}':")
+ for comment, line in occurrences:
+ comment_text = f" (comment: '{comment}')" if comment else " (no comment)"
+ print(f" Line {line}{comment_text}")
+ for (file_path, key), occurrences in plural_occurrences.items():
+ comments = set(c for c, _ in occurrences)
+ if len(occurrences) > 1 and len(comments) > 1:
+ has_intra_file_collisions = True
+ print(f"\n⚠️ Intra-file key collision in {file_path} for '{key}':")
+ for comment, line in occurrences:
+ comment_text = f" (comment: '{comment}')" if comment else " (no comment)"
+ print(f" Line {line}{comment_text}")
+ if has_intra_file_collisions and args.fail_on_collisions:
+ print(f"❌ Exiting due to intra-file key collisions (--fail-on-collisions flag)")
+ exit(1)
+
+ # Report collisions
+ has_collisions = False
+
+ if tr_collisions:
+ has_collisions = True
+ print(f"\n⚠️ Key collisions detected in tr! strings:")
+ for key, collisions in tr_collisions.items():
+ print(f" '{key}':")
+ for file_path, comment in collisions:
+ comment_text = f" (comment: '{comment}')" if comment else " (no comment)"
+ print(f" {file_path}{comment_text}")
+
+ if plural_collisions:
+ has_collisions = True
+ print(f"\n⚠️ Key collisions detected in tr_plural! strings:")
+ for key, collisions in plural_collisions.items():
+ print(f" '{key}':")
+ for file_path, comment in collisions:
+ comment_text = f" (comment: '{comment}')" if comment else " (no comment)"
+ print(f" {file_path}{comment_text}")
+
+ if has_collisions:
+ print(f"\n💡 Collision resolution: The last occurrence of each key will be used.")
+ if args.fail_on_collisions:
+ print(f"❌ Exiting due to key collisions (--fail-on-collisions flag)")
+ exit(1)
+
+ print(f"\nExtracted strings:")
+ print(f" Regular strings: {len(all_tr_strings)}")
+ print(f" Plural strings: {len(all_plural_strings)}")
+
+ # Debug: print all keys in all_tr_strings
+ print("[DEBUG] All tr! keys:")
+ for k in all_tr_strings.keys():
+ print(f" {k}")
+
+ # Generate FTL content for both locales
+ locales = ['en-US', 'en-XA']
+
+ for locale in locales:
+ pseudolocalize_content = (locale == 'en-XA')
+ ftl_content = generate_ftl_content(all_tr_strings, all_plural_strings, tr_occurrences, plural_occurrences, pseudolocalize_content)
+ output_path = Path(f'assets/translations/{locale}/main.ftl')
+
+ if args.dry_run:
+ print(f"\n--- Generated FTL content for {locale} ---")
+ print(ftl_content)
+ print(f"--- End of content for {locale} ---")
+ else:
+ # Ensure output directory exists
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Write to file
+ with open(output_path, 'w', encoding='utf-8') as f:
+ f.write(ftl_content)
+
+ print(f"\nGenerated FTL file: {output_path}")
+
+ if not args.dry_run:
+ print(f"\nTotal strings: {len(all_tr_strings) + len(all_plural_strings)}")
+
+if __name__ == '__main__':
+ main()
+\ No newline at end of file