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
This commit is contained in:
William Casarin
2025-07-22 13:49:37 -07:00
59 changed files with 6810 additions and 892 deletions

400
Cargo.lock generated
View File

@@ -204,7 +204,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -377,7 +377,7 @@ checksum = "0289cba6d5143bfe8251d57b4a8cac036adf158525a76533a7082ba65ec76398"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -407,7 +407,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -442,7 +442,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -490,9 +490,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.4.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "av1-grain" name = "av1-grain"
@@ -510,9 +510,9 @@ dependencies = [
[[package]] [[package]]
name = "avif-serialize" name = "avif-serialize"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" checksum = "19135c0c7a60bfee564dbe44ab5ce0557c6bf3884e5291a50be76a15640c4fbd"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
] ]
@@ -614,15 +614,15 @@ dependencies = [
"regex", "regex",
"rustc-hash 1.1.0", "rustc-hash 1.1.0",
"shlex", "shlex",
"syn 2.0.103", "syn 2.0.104",
"which", "which",
] ]
[[package]] [[package]]
name = "bip39" name = "bip39"
version = "2.1.0" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054"
dependencies = [ dependencies = [
"bitcoin_hashes 0.13.0", "bitcoin_hashes 0.13.0",
"serde", "serde",
@@ -810,9 +810,9 @@ checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.18.1" version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
@@ -831,7 +831,7 @@ checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -982,6 +982,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "chunky-vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb7bdea464ae038f09197b82430b921c53619fc8d2bcaf7b151013b3ca008017"
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@@ -1194,7 +1200,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1205,7 +1211,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [ dependencies = [
"darling_core", "darling_core",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1247,7 +1253,7 @@ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1257,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [ dependencies = [
"derive_builder_core", "derive_builder_core",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1334,7 +1340,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1553,6 +1559,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 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]] [[package]]
name = "emath" name = "emath"
version = "0.31.1" version = "0.31.1"
@@ -1613,7 +1628,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1634,7 +1649,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1645,7 +1660,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1697,7 +1712,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1708,12 +1723,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.12" version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1825,6 +1840,82 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -1855,7 +1946,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -1942,7 +2033,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -2030,9 +2121,9 @@ dependencies = [
[[package]] [[package]]
name = "gif" name = "gif"
version = "0.13.1" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [ dependencies = [
"color_quant", "color_quant",
"weezl", "weezl",
@@ -2354,7 +2445,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 1.0.0", "webpki-roots 1.0.1",
] ]
[[package]] [[package]]
@@ -2543,9 +2634,9 @@ dependencies = [
[[package]] [[package]]
name = "image-webp" name = "image-webp"
version = "0.2.2" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14d75c7014ddab93c232bc6bb9f64790d3dfd1d605199acd4b40b6d69e691e9f" checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b"
dependencies = [ dependencies = [
"byteorder-lite", "byteorder-lite",
"quick-error", "quick-error",
@@ -2604,7 +2695,26 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "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]] [[package]]
@@ -2744,9 +2854,9 @@ dependencies = [
[[package]] [[package]]
name = "jpeg-decoder" name = "jpeg-decoder"
version = "0.3.1" version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
@@ -2804,9 +2914,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.173" version = "0.2.174"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
@@ -2925,9 +3035,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "lz4_flex" name = "lz4_flex"
version = "0.11.4" version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c592ad9fbc1b7838633b3ae55ce69b17d01150c72fcef229fbb819d39ee51ee" checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
@@ -2957,6 +3067,12 @@ dependencies = [
"rayon", "rayon",
] ]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.5" version = "2.7.5"
@@ -3293,19 +3409,25 @@ dependencies = [
"egui-winit", "egui-winit",
"ehttp", "ehttp",
"enostr", "enostr",
"fluent",
"fluent-langneg",
"fluent-resmgr",
"hashbrown", "hashbrown",
"hex", "hex",
"image", "image",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)", "jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lightning-invoice", "lightning-invoice",
"md5",
"mime_guess", "mime_guess",
"nostr 0.37.0", "nostr 0.37.0",
"nostrdb", "nostrdb",
"nwc", "nwc",
"once_cell",
"poll-promise", "poll-promise",
"profiling", "profiling",
"puffin", "puffin",
"puffin_egui", "puffin_egui",
"regex",
"secp256k1 0.30.0", "secp256k1 0.30.0",
"serde", "serde",
"serde_json", "serde_json",
@@ -3317,6 +3439,7 @@ dependencies = [
"tokenator", "tokenator",
"tokio", "tokio",
"tracing", "tracing",
"unic-langid",
"url", "url",
"uuid", "uuid",
] ]
@@ -3494,7 +3617,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -3528,23 +3651,24 @@ dependencies = [
[[package]] [[package]]
name = "num_enum" name = "num_enum"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a"
dependencies = [ dependencies = [
"num_enum_derive", "num_enum_derive",
"rustversion",
] ]
[[package]] [[package]]
name = "num_enum_derive" name = "num_enum_derive"
version = "0.7.3" version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4031,7 +4155,7 @@ dependencies = [
"phf_shared", "phf_shared",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"unicase", "unicase",
] ]
@@ -4051,6 +4175,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-cell"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f4c4ebd3c5f82080164b7d9cc8e505cd9536fda8c750b779daceb4b7180a7b"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@@ -4068,7 +4198,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4192,12 +4322,12 @@ dependencies = [
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.34" version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4209,6 +4339,12 @@ dependencies = [
"toml_edit", "toml_edit",
] ]
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@@ -4220,9 +4356,9 @@ dependencies = [
[[package]] [[package]]
name = "profiling" name = "profiling"
version = "1.0.16" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
dependencies = [ dependencies = [
"profiling-procmacros", "profiling-procmacros",
"puffin", "puffin",
@@ -4230,12 +4366,12 @@ dependencies = [
[[package]] [[package]]
name = "profiling-procmacros" name = "profiling-procmacros"
version = "1.0.16" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [ dependencies = [
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -4338,9 +4474,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn-udp" name = "quinn-udp"
version = "0.5.12" version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
dependencies = [ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
@@ -4361,9 +4497,9 @@ dependencies = [
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.2.0" version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "rand" name = "rand"
@@ -4461,9 +4597,9 @@ dependencies = [
[[package]] [[package]]
name = "ravif" name = "ravif"
version = "0.11.12" version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6a5f31fcf7500f9401fea858ea4ab5525c99f2322cfcee732c0e6c74208c0c6" checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b"
dependencies = [ dependencies = [
"avif-serialize", "avif-serialize",
"imgref", "imgref",
@@ -4679,7 +4815,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots 1.0.0", "webpki-roots 1.0.1",
] ]
[[package]] [[package]]
@@ -4841,9 +4977,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.27" version = "0.23.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
dependencies = [ dependencies = [
"log", "log",
"once_cell", "once_cell",
@@ -5041,6 +5177,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "self_cell"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749"
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.26" version = "1.0.26"
@@ -5064,7 +5206,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5088,7 +5230,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5325,7 +5467,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5357,9 +5499,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.103" version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5383,7 +5525,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5467,7 +5609,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5478,7 +5620,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5614,7 +5756,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5784,13 +5926,13 @@ dependencies = [
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.29" version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -5921,6 +6063,49 @@ dependencies = [
"winapi", "winapi",
] ]
[[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]] [[package]]
name = "unicase" name = "unicase"
version = "2.8.1" version = "2.8.1"
@@ -5935,9 +6120,9 @@ checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
version = "0.1.22" version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
dependencies = [ dependencies = [
"tinyvec", "tinyvec",
] ]
@@ -6168,7 +6353,7 @@ dependencies = [
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -6203,7 +6388,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -6391,14 +6576,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [ dependencies = [
"webpki-roots 1.0.0", "webpki-roots 1.0.1",
] ]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.0" version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@@ -6620,7 +6805,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -6631,7 +6816,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -6642,7 +6827,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -6653,7 +6838,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -6735,6 +6920,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.42.2" version = "0.42.2"
@@ -7137,9 +7331,9 @@ checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d"
[[package]] [[package]]
name = "xcursor" name = "xcursor"
version = "0.3.8" version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]] [[package]]
name = "xkbcommon-dl" name = "xkbcommon-dl"
@@ -7198,7 +7392,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"synstructure", "synstructure",
] ]
@@ -7244,7 +7438,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"zbus_names", "zbus_names",
"zvariant", "zvariant",
"zvariant_utils", "zvariant_utils",
@@ -7264,22 +7458,22 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.25" version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.25" version = "0.8.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -7299,7 +7493,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"synstructure", "synstructure",
] ]
@@ -7339,7 +7533,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
] ]
[[package]] [[package]]
@@ -7359,9 +7553,9 @@ dependencies = [
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.4.17" version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f6fe2e33d02a98ee64423802e16df3de99c43e5cf5ff983767e1128b394c8ac" checksum = "7384255a918371b5af158218d131530f694de9ad3815ebdd0453a940485cb0fa"
dependencies = [ dependencies = [
"zune-core", "zune-core",
] ]
@@ -7390,7 +7584,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.103", "syn 2.0.104",
"zvariant_utils", "zvariant_utils",
] ]
@@ -7404,6 +7598,6 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"static_assertions", "static_assertions",
"syn 2.0.103", "syn 2.0.104",
"winnow", "winnow",
] ]

View File

@@ -30,10 +30,14 @@ egui_virtual_list = { git = "https://github.com/jb55/hello_egui", rev = "a66b679
ehttp = "0.5.0" ehttp = "0.5.0"
enostr = { path = "crates/enostr" } enostr = { path = "crates/enostr" }
ewebsock = { version = "0.2.0", features = ["tls"] } ewebsock = { version = "0.2.0", features = ["tls"] }
fluent = "0.17.0"
fluent-resmgr = "0.0.8"
fluent-langneg = "0.13"
hex = "0.4.3" hex = "0.4.3"
image = { version = "0.25", features = ["jpeg", "png", "webp"] } image = { version = "0.25", features = ["jpeg", "png", "webp"] }
indexmap = "2.6.0" indexmap = "2.6.0"
log = "0.4.17" log = "0.4.17"
md5 = "0.7.0"
nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] } nostr = { version = "0.37.0", default-features = false, features = ["std", "nip49"] }
nwc = "0.39.0" nwc = "0.39.0"
mio = { version = "1.0.3", features = ["os-poll", "net"] } 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_dave = { path = "crates/notedeck_dave" }
notedeck_ui = { path = "crates/notedeck_ui" } notedeck_ui = { path = "crates/notedeck_ui" }
tokenator = { path = "crates/tokenator" } tokenator = { path = "crates/tokenator" }
once_cell = "1.19.0"
open = "5.3.0" open = "5.3.0"
poll-promise = { version = "0.3.0", features = ["tokio"] } poll-promise = { version = "0.3.0", features = ["tokio"] }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" } 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-appender = "0.2.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tempfile = "3.13.0" tempfile = "3.13.0"
unic-langid = { version = "0.9.6", features = ["macros"] }
url = "2.5.2" url = "2.5.2"
urlencoding = "2.1.3" urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] } uuid = { version = "1.10.0", features = ["v4"] }

View File

@@ -111,6 +111,8 @@ Building on notedeck dev documentation is also on the roadmap.
## 🤝 Contributing ## 🤝 Contributing
### Developers
Contributions are welcome! Please check the developer documentation and follow these guidelines: Contributions are welcome! Please check the developer documentation and follow these guidelines:
1. Fork the repository 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`) 4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request 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 ## 🔒 Security
For security issues, please refer to our [Security Policy](./SECURITY.md). For security issues, please refer to our [Security Policy](./SECURITY.md).

View File

@@ -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'
}

View File

@@ -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}'
}

View File

@@ -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}'{"]"}
}

View File

@@ -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 }'
}

View File

@@ -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 }条结果
}

View File

@@ -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 }條結果
}

View File

@@ -39,6 +39,13 @@ bech32 = { workspace = true }
lightning-invoice = { workspace = true } lightning-invoice = { workspace = true }
secp256k1 = { workspace = true } secp256k1 = { workspace = true }
hashbrown = { 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] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }

View File

@@ -34,6 +34,11 @@ Notedeck is built around a modular architecture that separates concerns into dis
- `ColorTheme` - Theme management - `ColorTheme` - Theme management
- Various UI helpers - Various UI helpers
7. **Localization System**
- `LocalizationManager` - Core localization functionality
- `LocalizationContext` - Thread-safe context for sharing localization
- Fluent-based translation system
## Key Concepts ## Key Concepts
### Note Context and Actions ### Note Context and Actions
@@ -163,6 +168,197 @@ Notedeck provides several persistence mechanisms:
- `TimedSerializer` - For settings that need to be saved after a delay - `TimedSerializer` - For settings that need to be saved after a delay
- Various handlers for specific settings (zoom, theme, app size) - 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 ## Troubleshooting
### Common Issues ### Common Issues
@@ -182,6 +378,12 @@ Notedeck provides several persistence mechanisms:
- Check for large image caches - Check for large image caches
- Consider reducing the number of active subscriptions - 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 ## Contributing
When contributing to Notedeck: When contributing to Notedeck:
@@ -190,3 +392,5 @@ When contributing to Notedeck:
2. Add tests for new functionality 2. Add tests for new functionality
3. Update documentation as needed 3. Update documentation as needed
4. Keep performance in mind, especially for mobile targets 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

View File

@@ -1,4 +1,5 @@
use crate::account::FALLBACK_PUBKEY; use crate::account::FALLBACK_PUBKEY;
use crate::i18n::Localization;
use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::persist::{AppSizeHandler, ZoomHandler};
use crate::wallet::GlobalWallet; use crate::wallet::GlobalWallet;
use crate::zaps::Zaps; use crate::zaps::Zaps;
@@ -48,6 +49,7 @@ pub struct Notedeck {
zaps: Zaps, zaps: Zaps,
frame_history: FrameHistory, frame_history: FrameHistory,
job_pool: JobPool, job_pool: JobPool,
i18n: Localization,
} }
/// Our chrome, which is basically nothing /// Our chrome, which is basically nothing
@@ -227,6 +229,17 @@ impl Notedeck {
let zaps = Zaps::default(); let zaps = Zaps::default();
let job_pool = JobPool::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 { Self {
ndb, ndb,
img_cache, img_cache,
@@ -246,6 +259,7 @@ impl Notedeck {
clipboard: Clipboard::new(None), clipboard: Clipboard::new(None),
zaps, zaps,
job_pool, job_pool,
i18n,
} }
} }
@@ -270,6 +284,7 @@ impl Notedeck {
zaps: &mut self.zaps, zaps: &mut self.zaps,
frame_history: &mut self.frame_history, frame_history: &mut self.frame_history,
job_pool: &mut self.job_pool, job_pool: &mut self.job_pool,
i18n: &mut self.i18n,
} }
} }

View File

@@ -2,10 +2,12 @@ use std::collections::BTreeSet;
use enostr::{Keypair, Pubkey, SecretKey}; use enostr::{Keypair, Pubkey, SecretKey};
use tracing::error; use tracing::error;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
pub struct Args { pub struct Args {
pub relays: Vec<String>, pub relays: Vec<String>,
pub is_mobile: Option<bool>, pub is_mobile: Option<bool>,
pub locale: Option<LanguageIdentifier>,
pub show_note_client: bool, pub show_note_client: bool,
pub keys: Vec<Keypair>, pub keys: Vec<Keypair>,
pub light: bool, pub light: bool,
@@ -36,6 +38,7 @@ impl Args {
use_keystore: true, use_keystore: true,
dbpath: None, dbpath: None,
datapath: None, datapath: None,
locale: None,
}; };
let mut i = 0; let mut i = 0;
@@ -47,6 +50,23 @@ impl Args {
res.is_mobile = Some(true); res.is_mobile = Some(true);
} else if arg == "--light" { } else if arg == "--light" {
res.light = true; 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" { } else if arg == "--dark" {
res.light = false; res.light = false;
} else if arg == "--debug" { } else if arg == "--debug" {

View File

@@ -1,6 +1,7 @@
use crate::{ use crate::{
account::accounts::Accounts, frame_history::FrameHistory, wallet::GlobalWallet, zaps::Zaps, account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization,
Args, DataPath, Images, JobPool, NoteCache, ThemeHandler, UnknownIds, wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler,
UnknownIds,
}; };
use egui_winit::clipboard::Clipboard; use egui_winit::clipboard::Clipboard;
@@ -24,4 +25,5 @@ pub struct AppContext<'a> {
pub zaps: &'a mut Zaps, pub zaps: &'a mut Zaps,
pub frame_history: &'a mut FrameHistory, pub frame_history: &'a mut FrameHistory,
pub job_pool: &'a mut JobPool, pub job_pool: &'a mut JobPool,
pub i18n: &'a mut Localization,
} }

View File

@@ -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),
}

View File

@@ -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
}
}

View File

@@ -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])
}

View File

@@ -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, )
}};
}

View File

@@ -9,6 +9,7 @@ mod error;
pub mod filter; pub mod filter;
pub mod fonts; pub mod fonts;
mod frame_history; mod frame_history;
pub mod i18n;
mod imgcache; mod imgcache;
mod job_pool; mod job_pool;
mod muted; mod muted;
@@ -44,6 +45,7 @@ pub use context::AppContext;
pub use error::{show_one_error_message, Error, FilterError, ZapError}; pub use error::{show_one_error_message, Error, FilterError, ZapError};
pub use filter::{FilterState, FilterStates, UnifiedSubscription}; pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily; pub use fonts::NamedFontFamily;
pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization};
pub use imgcache::{ pub use imgcache::{
Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache, Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache,
MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache, MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache,

View File

@@ -6,6 +6,7 @@ pub use context::{BroadcastContext, ContextSelection, NoteContextSelection};
use crate::Accounts; use crate::Accounts;
use crate::JobPool; use crate::JobPool;
use crate::Localization;
use crate::UnknownIds; use crate::UnknownIds;
use crate::{notecache::NoteCache, zaps::Zaps, Images}; use crate::{notecache::NoteCache, zaps::Zaps, Images};
use enostr::{NoteId, RelayPool}; use enostr::{NoteId, RelayPool};
@@ -19,6 +20,7 @@ use std::fmt;
pub struct NoteContext<'d> { pub struct NoteContext<'d> {
pub ndb: &'d Ndb, pub ndb: &'d Ndb,
pub accounts: &'d Accounts, pub accounts: &'d Accounts,
pub i18n: &'d mut Localization,
pub img_cache: &'d mut Images, pub img_cache: &'d mut Images,
pub note_cache: &'d mut NoteCache, pub note_cache: &'d mut NoteCache,
pub zaps: &'d mut Zaps, pub zaps: &'d mut Zaps,

View File

@@ -1,7 +1,5 @@
use crate::{time_ago_since, TimeCached};
use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf}; use nostrdb::{Note, NoteKey, NoteReply, NoteReplyBuf};
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration;
#[derive(Default)] #[derive(Default)]
pub struct NoteCache { pub struct NoteCache {
@@ -32,7 +30,7 @@ impl NoteCache {
#[derive(Clone)] #[derive(Clone)]
pub struct CachedNote { pub struct CachedNote {
reltime: TimeCached<String>, //reltime: TimeCached<String>,
pub client: Option<String>, pub client: Option<String>,
pub reply: NoteReplyBuf, pub reply: NoteReplyBuf,
} }
@@ -41,22 +39,25 @@ impl CachedNote {
pub fn new(note: &Note) -> Self { pub fn new(note: &Note) -> Self {
use crate::note::event_tag; use crate::note::event_tag;
/*
let created_at = note.created_at(); let created_at = note.created_at();
let reltime = TimeCached::new( let reltime = TimeCached::new(
Duration::from_secs(1), 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 reply = NoteReply::new(note.tags()).to_owned();
let client = event_tag(note, "client"); let client = event_tag(note, "client");
CachedNote { CachedNote {
client: client.map(|c| c.to_string()), client: client.map(|c| c.to_string()),
reltime, // reltime,
reply, reply,
} }
} }
/*
pub fn reltime_str_mut(&mut self) -> &str { pub fn reltime_str_mut(&mut self) -> &str {
self.reltime.get_mut() self.reltime.get_mut()
} }
@@ -64,4 +65,5 @@ impl CachedNote {
pub fn reltime_str(&self) -> Option<&str> { pub fn reltime_str(&self) -> Option<&str> {
self.reltime.get().map(|x| x.as_str()) self.reltime.get().map(|x| x.as_str())
} }
*/
} }

View File

@@ -1,11 +1,24 @@
use crate::{tr, Localization};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub fn time_ago_since(timestamp: u64) -> String { // Time duration constants in seconds
let now = SystemTime::now() const ONE_MINUTE_IN_SECONDS: u64 = 60;
.duration_since(UNIX_EPOCH) const ONE_HOUR_IN_SECONDS: u64 = 3600;
.expect("Time went backwards") const ONE_DAY_IN_SECONDS: u64 = 86_400;
.as_secs(); 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 // Determine if the timestamp is in the future or the past
let duration = if now >= timestamp { let duration = if now >= timestamp {
now.saturating_sub(timestamp) now.saturating_sub(timestamp)
@@ -13,43 +26,321 @@ pub fn time_ago_since(timestamp: u64) -> String {
timestamp.saturating_sub(now) timestamp.saturating_sub(now)
}; };
let future = timestamp > now; let time_str = match duration {
let relstr = if future { "+" } else { "" }; 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 timestamp > now {
if years >= 1 { format!("+{time_str}")
return format!("{relstr}{years}yr"); } else {
time_str
}
}
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()
}
#[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
);
}
#[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
);
}
#[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
);
}
#[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
);
}
#[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
);
}
#[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
);
} }
let months = duration / 2_592_000; // seconds in a month (30.44 days)
if months >= 1 {
return format!("{relstr}{months}mth");
}
let weeks = duration / 604_800; // seconds in a week
if weeks >= 1 {
return format!("{relstr}{weeks}wk");
}
let days = duration / 86_400; // seconds in a day
if days >= 1 {
return format!("{relstr}{days}d");
}
let hours = duration / 3600; // seconds in an hour
if hours >= 1 {
return format!("{relstr}{hours}h");
}
let minutes = duration / 60; // seconds in a minute
if minutes >= 1 {
return format!("{relstr}{minutes}m");
}
let seconds = duration;
if seconds >= 3 {
return format!("{relstr}{seconds}s");
}
"now".to_string()
} }

View File

@@ -153,8 +153,8 @@ impl From<nwc::Error> for NwcError {
impl Display for NwcError { impl Display for NwcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
NwcError::NIP47(err) => write!(f, "NIP47 error: {}", err), NwcError::NIP47(err) => write!(f, "NIP47 error: {err}"),
NwcError::Relay(err) => write!(f, "Relay error: {}", err), NwcError::Relay(err) => write!(f, "Relay error: {err}"),
NwcError::PrematureExit => write!(f, "Premature exit"), NwcError::PrematureExit => write!(f, "Premature exit"),
NwcError::Timeout => write!(f, "Request timed out"), NwcError::Timeout => write!(f, "Request timed out"),
} }

View File

@@ -5,7 +5,9 @@ use crate::app::NotedeckApp;
use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget}; use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget};
use egui_extras::{Size, StripBuilder}; use egui_extras::{Size, StripBuilder};
use nostrdb::{ProfileRecord, Transaction}; 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::{ use notedeck_columns::{
column::SelectionResult, timeline::kind::ListKind, timeline::TimelineKind, Damus, column::SelectionResult, timeline::kind::ListKind, timeline::TimelineKind, Damus,
}; };
@@ -59,14 +61,17 @@ pub enum ChromePanelAction {
} }
impl 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(); chrome.switch_to_columns();
let Some(columns_app) = chrome.get_columns_app() else { let Some(columns_app) = chrome.get_columns_app() else {
return; 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) { match active_columns.select_by_kind(kind) {
SelectionResult::NewSelection(_index) => { SelectionResult::NewSelection(_index) => {
// great! no need to go to top yet // 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(); chrome.switch_to_columns();
if let Some(c) = chrome if let Some(c) = chrome.get_columns_app().and_then(|columns| {
.get_columns_app() columns
.and_then(|columns| columns.decks_cache.selected_column_mut(ctx.accounts)) .decks_cache
{ .selected_column_mut(ctx.i18n, ctx.accounts)
}) {
if c.router().routes().iter().any(|r| r == &route) { if c.router().routes().iter().any(|r| r == &route) {
// return if we are already routing to accounts // return if we are already routing to accounts
c.router_mut().go_back(); 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 { match self {
Self::SaveTheme(theme) => { Self::SaveTheme(theme) => {
ui.ctx().options_mut(|o| { ui.ctx().options_mut(|o| {
@@ -244,7 +250,7 @@ impl Chrome {
.vertical(|mut vstrip| { .vertical(|mut vstrip| {
vstrip.cell(|ui| { vstrip.cell(|ui| {
_ = ui.vertical_centered(|ui| { _ = ui.vertical_centered(|ui| {
self.topdown_sidebar(ui); self.topdown_sidebar(ui, app_ctx.i18n);
}) })
}); });
vstrip.cell(|ui| { 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 // macos needs a bit of space to make room for window
// minimize/close buttons // minimize/close buttons
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
@@ -417,7 +423,7 @@ impl Chrome {
} }
ui.add_space(4.0); ui.add_space(4.0);
ui.add(milestone_name()); ui.add(milestone_name(i18n));
ui.add_space(16.0); ui.add_space(16.0);
//let dark_mode = ui.ctx().style().visuals.dark_mode; //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: &mut egui::Ui| -> egui::Response {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
let font = egui::FontId::new( let font = egui::FontId::new(
@@ -460,15 +466,17 @@ fn milestone_name() -> impl Widget {
); );
ui.add( ui.add(
Label::new( Label::new(
RichText::new("BETA") RichText::new(tr!(i18n, "BETA", "Beta version label"))
.color(ui.style().visuals.noninteractive().fg_stroke.color) .color(ui.style().visuals.noninteractive().fg_stroke.color)
.font(font), .font(font),
) )
.selectable(false), .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.", "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) .on_hover_cursor(egui::CursorIcon::Help)
}) })
.inner .inner
@@ -656,7 +664,7 @@ fn chrome_handle_app_action(
let cols = columns let cols = columns
.decks_cache .decks_cache
.active_columns_mut(ctx.accounts) .active_columns_mut(ctx.i18n, ctx.accounts)
.unwrap(); .unwrap();
let m_action = notedeck_columns::actionbar::execute_and_process_note_action( let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
note_action, note_action,
@@ -719,7 +727,11 @@ fn bottomup_sidebar(
let resp = ui let resp = ui
.add(Button::new("").frame(false)) .add(Button::new("").frame(false))
.on_hover_cursor(egui::CursorIcon::PointingHand) .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() { if resp.clicked() {
Some(ChromePanelAction::SaveTheme(ThemePreference::Light)) Some(ChromePanelAction::SaveTheme(ThemePreference::Light))
} else { } else {
@@ -730,7 +742,11 @@ fn bottomup_sidebar(
let resp = ui let resp = ui
.add(Button::new("🌙").frame(false)) .add(Button::new("🌙").frame(false))
.on_hover_cursor(egui::CursorIcon::PointingHand) .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() { if resp.clicked() {
Some(ChromePanelAction::SaveTheme(ThemePreference::Dark)) Some(ChromePanelAction::SaveTheme(ThemePreference::Dark))
} else { } else {

View File

@@ -1,7 +1,7 @@
use enostr::{FullKeypair, Pubkey}; use enostr::{FullKeypair, Pubkey};
use nostrdb::{Ndb, Transaction}; 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::app::get_active_columns_mut;
use crate::decks::DecksCache; use crate::decks::DecksCache;
@@ -72,23 +72,34 @@ pub fn render_accounts_route(
route: AccountsRoute, route: AccountsRoute,
) -> AddAccountAction { ) -> AddAccountAction {
let resp = match route { let resp = match route {
AccountsRoute::Accounts => { AccountsRoute::Accounts => AccountsView::new(
AccountsView::new(app_ctx.ndb, app_ctx.accounts, app_ctx.img_cache) app_ctx.ndb,
app_ctx.accounts,
app_ctx.img_cache,
app_ctx.i18n,
)
.ui(ui) .ui(ui)
.inner .inner
.map(AccountsRouteResponse::Accounts) .map(AccountsRouteResponse::Accounts),
}
AccountsRoute::AddAccount => AccountLoginView::new(login_state, app_ctx.clipboard) AccountsRoute::AddAccount => {
AccountLoginView::new(login_state, app_ctx.clipboard, app_ctx.i18n)
.ui(ui) .ui(ui)
.inner .inner
.map(AccountsRouteResponse::AddAccount), .map(AccountsRouteResponse::AddAccount)
}
}; };
if let Some(resp) = resp { if let Some(resp) = resp {
match resp { match resp {
AccountsRouteResponse::Accounts(response) => { 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 { AddAccountAction {
accounts_action: action, accounts_action: action,
unk_id_action: SingleUnkIdAction::no_action(), unk_id_action: SingleUnkIdAction::no_action(),
@@ -98,7 +109,7 @@ pub fn render_accounts_route(
let action = let action =
process_login_view_response(app_ctx, timeline_cache, decks, col, response); process_login_view_response(app_ctx, timeline_cache, decks, col, response);
*login_state = Default::default(); *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) .column_mut(col)
.router_mut(); .router_mut();
router.go_back(); router.go_back();
@@ -114,12 +125,13 @@ pub fn render_accounts_route(
} }
pub fn process_accounts_view_response( pub fn process_accounts_view_response(
i18n: &mut Localization,
accounts: &mut Accounts, accounts: &mut Accounts,
decks: &mut DecksCache, decks: &mut DecksCache,
col: usize, col: usize,
response: AccountsViewResponse, response: AccountsViewResponse,
) -> Option<AccountsAction> { ) -> Option<AccountsAction> {
let router = get_active_columns_mut(accounts, decks) let router = get_active_columns_mut(i18n, accounts, decks)
.column_mut(col) .column_mut(col)
.router_mut(); .router_mut();
let mut action = None; let mut action = None;

View File

@@ -284,7 +284,7 @@ impl NewNotes {
let timeline = if let Some(profile) = timeline_cache.get_mut(&self.id) { let timeline = if let Some(profile) = timeline_cache.get_mut(&self.id) {
profile profile
} else { } else {
error!("NewNotes: could not get timeline for key {}", self.id); error!("NewNotes: could not get timeline for key {:?}", self.id);
return; return;
}; };

View File

@@ -19,7 +19,8 @@ use egui_extras::{Size, StripBuilder};
use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::{ 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 notedeck_ui::{jobs::JobsCache, NoteOptions};
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
@@ -91,7 +92,8 @@ fn try_process_event(
app_ctx: &mut AppContext<'_>, app_ctx: &mut AppContext<'_>,
ctx: &egui::Context, ctx: &egui::Context,
) -> Result<()> { ) -> 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)); ctx.input(|i| handle_key_events(i, current_columns));
let ctx2 = ctx.clone(); 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(); app_ctx.img_cache.urls.cache.handle_io();
if damus.columns(app_ctx.accounts).columns().is_empty() { 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 { match damus.state {
@@ -261,7 +265,7 @@ fn handle_eose(
tl tl
} else { } else {
error!( error!(
"timeline uid:{} not found for FetchingContactList", "timeline uid:{:?} not found for FetchingContactList",
timeline_uid timeline_uid
); );
return Ok(()); 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) = } 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!( info!(
"DecksCache: loading from disk {}", "DecksCache: loading from disk {}",
@@ -494,8 +498,8 @@ impl Damus {
self.options.insert(AppOptions::ScrollToTop) self.options.insert(AppOptions::ScrollToTop)
} }
pub fn columns_mut(&mut self, accounts: &Accounts) -> &mut Columns { pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns {
get_active_columns_mut(accounts, &mut self.decks_cache) get_active_columns_mut(i18n, accounts, &mut self.decks_cache)
} }
pub fn columns(&self, accounts: &Accounts) -> &Columns { pub fn columns(&self, accounts: &Accounts) -> &Columns {
@@ -511,7 +515,8 @@ impl Damus {
} }
pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { 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 path = DataPath::new(&data_path);
let imgcache_dir = path.path(DataPathType::Cache); let imgcache_dir = path.path(DataPathType::Cache);
@@ -566,7 +571,7 @@ fn render_damus_mobile(
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
let mut app_action: Option<AppAction> = None; 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() { if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav( let r = nav::render_nav(
active_col, active_col,
@@ -622,6 +627,7 @@ fn hovering_post_button(
&mut app.decks_cache, &mut app.decks_cache,
app_ctx.accounts, app_ctx.accounts,
SidePanelAction::ComposeNote, SidePanelAction::ComposeNote,
app_ctx.i18n,
); );
} }
} }
@@ -714,8 +720,11 @@ fn timelines_view(
.horizontal(|mut strip| { .horizontal(|mut strip| {
strip.cell(|ui| { strip.cell(|ui| {
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
let side_panel = let side_panel = DesktopSidePanel::new(
DesktopSidePanel::new(ctx.accounts.get_selected_account(), &app.decks_cache) ctx.accounts.get_selected_account(),
&app.decks_cache,
ctx.i18n,
)
.show(ui); .show(ui);
if let Some(side_panel) = side_panel { if let Some(side_panel) = side_panel {
@@ -724,6 +733,7 @@ fn timelines_view(
&mut app.decks_cache, &mut app.decks_cache,
ctx.accounts, ctx.accounts,
side_panel.action, side_panel.action,
ctx.i18n,
) { ) {
side_panel_action = Some(action); 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>( pub fn get_active_columns_mut<'a>(
i18n: &mut Localization,
accounts: &Accounts, accounts: &Accounts,
decks_cache: &'a mut DecksCache, decks_cache: &'a mut DecksCache,
) -> &'a mut Columns { ) -> &'a mut Columns {
get_decks_mut(accounts, decks_cache) get_decks_mut(i18n, accounts, decks_cache)
.active_mut() .active_mut()
.columns_mut() .columns_mut()
} }
pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks { pub fn get_decks_mut<'a>(
decks_cache.decks_mut(accounts.selected_account_pubkey()) 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 mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
let decks = Decks::new(crate::decks::Deck::new_with_columns( let decks = Decks::new(crate::decks::Deck::new_with_columns(
crate::decks::Deck::default().icon, crate::decks::Deck::default_icon(),
"My Deck".to_owned(), tr!(i18n, "My Deck", "Title for the user's deck"),
cols, cols,
)); ));
let account = Pubkey::new(*key); let account = Pubkey::new(*key);
account_to_decks.insert(account, decks); account_to_decks.insert(account, decks);
DecksCache::new(account_to_decks) DecksCache::new(account_to_decks, i18n)
} }

View File

@@ -2,7 +2,7 @@ use std::collections::{hash_map::ValuesMut, HashMap};
use enostr::{Pubkey, RelayPool}; use enostr::{Pubkey, RelayPool};
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::{AppContext, FALLBACK_PUBKEY}; use notedeck::{tr, AppContext, Localization, FALLBACK_PUBKEY};
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
@@ -21,18 +21,20 @@ pub struct DecksCache {
fallback_pubkey: Pubkey, fallback_pubkey: Pubkey,
} }
impl Default for DecksCache {
fn default() -> Self {
let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
account_to_decks.insert(FALLBACK_PUBKEY(), Decks::default());
DecksCache::new(account_to_decks)
}
}
impl DecksCache { 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_decks(i18n));
DecksCache::new(account_to_decks, i18n)
}
/// Gets the first column in the currently active user's active deck /// Gets the first column in the currently active user's active deck
pub fn selected_column_mut(&mut self, accounts: &notedeck::Accounts) -> Option<&mut Column> { pub fn selected_column_mut(
self.active_columns_mut(accounts) &mut self,
i18n: &mut Localization,
accounts: &notedeck::Accounts,
) -> Option<&mut Column> {
self.active_columns_mut(i18n, accounts)
.and_then(|ad| ad.selected_mut()) .and_then(|ad| ad.selected_mut())
} }
@@ -45,10 +47,14 @@ impl DecksCache {
} }
/// Gets a mutable reference to the active columns /// Gets a mutable reference to the active columns
pub fn active_columns_mut(&mut self, accounts: &notedeck::Accounts) -> Option<&mut Columns> { pub fn active_columns_mut(
&mut self,
i18n: &mut Localization,
accounts: &notedeck::Accounts,
) -> Option<&mut Columns> {
let account = accounts.get_selected_account(); let account = accounts.get_selected_account();
self.decks_mut(&account.key.pubkey) self.decks_mut(i18n, &account.key.pubkey)
.active_deck_mut() .active_deck_mut()
.map(|ad| ad.columns_mut()) .map(|ad| ad.columns_mut())
} }
@@ -62,9 +68,11 @@ impl DecksCache {
.map(|ad| ad.columns()) .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(); 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 { Self {
account_to_decks, account_to_decks,
@@ -79,7 +87,7 @@ impl DecksCache {
fallback_pubkey, fallback_pubkey,
demo_decks(fallback_pubkey, timeline_cache, ctx), 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 { pub fn decks(&self, key: &Pubkey) -> &Decks {
@@ -88,8 +96,10 @@ impl DecksCache {
.unwrap_or_else(|| self.fallback()) .unwrap_or_else(|| self.fallback())
} }
pub fn decks_mut(&mut self, key: &Pubkey) -> &mut Decks { pub fn decks_mut(&mut self, i18n: &mut Localization, key: &Pubkey) -> &mut Decks {
self.account_to_decks.entry(*key).or_default() self.account_to_decks
.entry(*key)
.or_insert_with(|| Decks::default_decks(i18n))
} }
pub fn fallback(&self) -> &Decks { pub fn fallback(&self) -> &Decks {
@@ -110,7 +120,7 @@ impl DecksCache {
timeline_cache: &mut TimelineCache, timeline_cache: &mut TimelineCache,
pubkey: Pubkey, pubkey: Pubkey,
) { ) {
let mut decks = Decks::default(); let mut decks = Decks::default_decks(ctx.i18n);
// add home and notifications for new accounts // add home and notifications for new accounts
add_demo_columns( add_demo_columns(
@@ -157,6 +167,7 @@ impl DecksCache {
pub fn remove( pub fn remove(
&mut self, &mut self,
i18n: &mut Localization,
key: &Pubkey, key: &Pubkey,
timeline_cache: &mut TimelineCache, timeline_cache: &mut TimelineCache,
ndb: &mut nostrdb::Ndb, ndb: &mut nostrdb::Ndb,
@@ -171,7 +182,7 @@ impl DecksCache {
if !self.account_to_decks.contains_key(&self.fallback_pubkey) { if !self.account_to_decks.contains_key(&self.fallback_pubkey) {
self.account_to_decks 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>, decks: Vec<Deck>,
} }
impl Default for Decks {
fn default() -> Self {
Decks::new(Deck::default())
}
}
impl Decks { impl Decks {
pub fn default_decks(i18n: &mut Localization) -> Self {
Decks::new(Deck::default_deck(i18n))
}
pub fn new(deck: Deck) -> Self { pub fn new(deck: Deck) -> Self {
let decks = vec![deck]; let decks = vec![deck];
@@ -381,24 +390,22 @@ pub struct Deck {
columns: Columns, columns: Columns,
} }
impl Default for Deck {
fn default() -> Self {
let columns = Columns::default();
Self {
columns,
icon: Deck::default_icon(),
name: Deck::default_name().to_string(),
}
}
}
impl Deck { impl Deck {
pub fn default_icon() -> char { pub fn default_icon() -> char {
'🇩' '🇩'
} }
pub fn default_name() -> &'static str { fn default_deck(i18n: &mut Localization) -> Self {
"Default Deck" let columns = Columns::default();
Self {
columns,
icon: Deck::default_icon(),
name: Deck::default_name(i18n).to_string(),
}
}
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 { pub fn new(icon: char, name: String) -> Self {
@@ -482,7 +489,7 @@ pub fn demo_decks(
Deck { Deck {
icon: Deck::default_icon(), icon: Deck::default_icon(),
name: Deck::default_name().to_string(), name: Deck::default_name(ctx.i18n).to_string(),
columns, columns,
} }
}; };

View File

@@ -2,6 +2,7 @@ use crate::key_parsing::perform_key_retrieval;
use crate::key_parsing::AcquireKeyError; use crate::key_parsing::AcquireKeyError;
use egui::{TextBuffer, TextEdit}; use egui::{TextBuffer, TextEdit};
use enostr::Keypair; use enostr::Keypair;
use notedeck::{tr, Localization};
use poll_promise::Promise; use poll_promise::Promise;
/// The state data for acquiring a nostr key /// 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 /// Get the textedit for the UI without exposing the key variable
pub fn get_acquire_textedit( pub fn get_acquire_textedit(
&'a mut self, &'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<'a> {
textedit_closure(&mut self.desired_key) textedit_closure(&mut self.desired_key)
} }
@@ -105,7 +106,7 @@ impl<'a> AcquireKeyState {
self.should_create_new 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.add_space(8.0);
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
@@ -115,7 +116,7 @@ impl<'a> AcquireKeyState {
}); });
if let Some(err) = self.check_for_error() { if let Some(err) = self.check_for_error() {
show_error(ui, err); show_error(ui, i18n, err);
} }
ui.add_space(8.0); 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| { ui.horizontal(|ui| {
let error_label = match err { let error_label = match err {
AcquireKeyError::InvalidKey => egui::Label::new( 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) => { AcquireKeyError::Nip05Failed(e) => {
egui::Label::new(egui::RichText::new(e).color(ui.visuals().error_fg_color)) egui::Label::new(egui::RichText::new(e).color(ui.visuals().error_fg_color))

View File

@@ -31,8 +31,8 @@ use egui_nav::{Nav, NavAction, NavResponse, NavUiType, Percent, PopupResponse, P
use enostr::ProfileState; use enostr::ProfileState;
use nostrdb::{Filter, Ndb, Transaction}; use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{ use notedeck::{
get_current_default_msats, get_current_wallet, ui::is_narrow, Accounts, AppContext, NoteAction, get_current_default_msats, get_current_wallet, tr, ui::is_narrow, Accounts, AppContext,
NoteContext, RelayAction, NoteAction, NoteContext, RelayAction,
}; };
use tracing::error; use tracing::error;
@@ -89,7 +89,7 @@ impl SwitchingAction {
ui_ctx, ui_ctx,
); );
// pop nav after switch // 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) .column_mut(switch_action.source_column)
.router_mut() .router_mut()
.go_back(); .go_back();
@@ -102,13 +102,13 @@ impl SwitchingAction {
break 's; 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 { SwitchingAction::Columns(columns_action) => match *columns_action {
ColumnsAction::Remove(index) => { ColumnsAction::Remove(index) => {
let kinds_to_pop = let kinds_to_pop = get_active_columns_mut(ctx.i18n, ctx.accounts, decks_cache)
get_active_columns_mut(ctx.accounts, decks_cache).delete_column(index); .delete_column(index);
for kind in &kinds_to_pop { for kind in &kinds_to_pop {
if let Err(err) = timeline_cache.pop(kind, ctx.ndb, ctx.pool) { if let Err(err) = timeline_cache.pop(kind, ctx.ndb, ctx.pool) {
error!("error popping timeline: {err}"); error!("error popping timeline: {err}");
@@ -117,15 +117,15 @@ impl SwitchingAction {
} }
ColumnsAction::Switch(from, to) => { 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 { SwitchingAction::Decks(decks_action) => match *decks_action {
DecksAction::Switch(index) => { 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) => { DecksAction::Removing(index) => {
get_decks_mut(ctx.accounts, decks_cache).remove_deck( get_decks_mut(ctx.i18n, ctx.accounts, decks_cache).remove_deck(
index, index,
timeline_cache, timeline_cache,
ctx.ndb, ctx.ndb,
@@ -206,10 +206,10 @@ fn process_popup_resp(
} }
if let Some(NavAction::Returned(_)) = action.action { 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(); column.sheet_router.clear();
} else if let Some(NavAction::Navigating) = action.action { } 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; column.sheet_router.navigating = false;
} }
@@ -235,7 +235,7 @@ fn process_nav_resp(
match action { match action {
NavAction::Returned(return_type) => { NavAction::Returned(return_type) => {
let r = app let r = app
.columns_mut(ctx.accounts) .columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.pop(); .pop();
@@ -260,7 +260,10 @@ fn process_nav_resp(
} }
NavAction::Navigated => { 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; cur_router.navigating = false;
if cur_router.is_replacing() { if cur_router.is_replacing() {
cur_router.remove_previous_routes(); cur_router.remove_previous_routes();
@@ -414,7 +417,7 @@ fn process_render_nav_action(
RenderNavAction::Back => Some(RouterAction::GoBack), RenderNavAction::Back => Some(RouterAction::GoBack),
RenderNavAction::PfpClicked => Some(RouterAction::PfpClicked), RenderNavAction::PfpClicked => Some(RouterAction::PfpClicked),
RenderNavAction::RemoveColumn => { 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 { for kind in &kinds_to_pop {
if let Err(err) = app.timeline_cache.pop(kind, ctx.ndb, ctx.pool) { 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( crate::actionbar::execute_and_process_note_action(
note_action, note_action,
ctx.ndb, 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, col,
&mut app.timeline_cache, &mut app.timeline_cache,
&mut app.threads, &mut app.threads,
@@ -480,7 +483,8 @@ fn process_render_nav_action(
}; };
if let Some(action) = router_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 router = &mut cols.router;
let sheet_router = &mut cols.sheet_router; let sheet_router = &mut cols.sheet_router;
@@ -511,6 +515,7 @@ fn render_nav_body(
unknown_ids: ctx.unknown_ids, unknown_ids: ctx.unknown_ids,
clipboard: ctx.clipboard, clipboard: ctx.clipboard,
current_account_has_wallet, current_account_has_wallet,
i18n: ctx.i18n,
}; };
match top { match top {
Route::Timeline(kind) => { Route::Timeline(kind) => {
@@ -565,21 +570,29 @@ fn render_nav_body(
.accounts_action .accounts_action
.map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) .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) .ui(ui)
.map(RenderNavAction::RelayAction), .map(RenderNavAction::RelayAction),
Route::Reply(id) => { Route::Reply(id) => {
let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) {
txn txn
} else { } 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; return None;
}; };
let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) { let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) {
note note
} else { } 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; 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()) { let note = if let Ok(note) = ctx.ndb.get_note_by_id(&txn, id.bytes()) {
note note
} else { } 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; return None;
}; };
@@ -667,12 +684,13 @@ fn render_nav_body(
None None
} }
Route::Support => { Route::Support => {
SupportView::new(&mut app.support).show(ui); SupportView::new(&mut app.support, ctx.i18n).show(ui);
None None
} }
Route::Search => { Route::Search => {
let id = ui.id().with(("search", depth, col)); let id = ui.id().with(("search", depth, col));
let navigating = get_active_columns_mut(ctx.accounts, &mut app.decks_cache) let navigating =
get_active_columns_mut(note_context.i18n, ctx.accounts, &mut app.decks_cache)
.column(col) .column(col)
.router() .router()
.navigating; .navigating;
@@ -702,13 +720,13 @@ fn render_nav_body(
let id = ui.id().with("new-deck"); let id = ui.id().with("new-deck");
let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default(); let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default();
let mut resp = None; 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(); let cur_acc = ctx.accounts.selected_account_pubkey();
app.decks_cache app.decks_cache
.add_deck(*cur_acc, Deck::new(config_resp.icon, config_resp.name)); .add_deck(*cur_acc, Deck::new(config_resp.icon, config_resp.name));
// set new deck as active // 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() .decks()
.len() .len()
- 1; - 1;
@@ -717,7 +735,7 @@ fn render_nav_body(
))); )));
new_deck_state.clear(); 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() .get_first_router()
.go_back(); .go_back();
} }
@@ -725,7 +743,7 @@ fn render_nav_body(
} }
Route::EditDeck(index) => { Route::EditDeck(index) => {
let mut action = None; 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() .decks_mut()
.get_mut(*index) .get_mut(*index)
.expect("index wasn't valid"); .expect("index wasn't valid");
@@ -737,7 +755,7 @@ fn render_nav_body(
.id_to_deck_state .id_to_deck_state
.entry(id) .entry(id)
.or_insert_with(|| DeckState::from_deck(cur_deck)); .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 { match resp {
EditDeckResponse::Edit(configure_deck_response) => { EditDeckResponse::Edit(configure_deck_response) => {
cur_deck.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() .get_first_router()
.go_back(); .go_back();
} }
@@ -769,7 +787,7 @@ fn render_nav_body(
return action; 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) { if let Some(state) = app.view_state.pubkey_to_profile_state.get(kp.pubkey) {
action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges( action = Some(RenderNavAction::ProfileAction(ProfileAction::SaveChanges(
SaveProfileChanges::new(kp.to_full(), state.clone()), 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) .ui(ui)
.map(RenderNavAction::WalletAction) .map(RenderNavAction::WalletAction)
} }
@@ -832,6 +850,7 @@ fn render_nav_body(
let txn = Transaction::new(ctx.ndb).expect("txn"); let txn = Transaction::new(ctx.ndb).expect("txn");
let default_msats = get_current_default_msats(ctx.accounts, ctx.global_wallet); let default_msats = get_current_default_msats(ctx.accounts, ctx.global_wallet);
CustomZapView::new( CustomZapView::new(
ctx.i18n,
ctx.img_cache, ctx.img_cache,
ctx.ndb, ctx.ndb,
&txn, &txn,
@@ -840,7 +859,7 @@ fn render_nav_body(
) )
.ui(ui) .ui(ui)
.map(|msats| { .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) .column_mut(col)
.router_mut() .router_mut()
.go_back(); .go_back();
@@ -895,9 +914,10 @@ pub fn render_nav(
NavUiType::Title => NavTitle::new( NavUiType::Title => NavTitle::new(
ctx.ndb, ctx.ndb,
ctx.img_cache, 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()], &[route.clone()],
col, col,
ctx.i18n,
) )
.show_move_button(!narrow) .show_move_button(!narrow)
.show_delete_button(!narrow) .show_delete_button(!narrow)
@@ -917,13 +937,13 @@ pub fn render_nav(
.clone(), .clone(),
) )
.navigating( .navigating(
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.navigating, .navigating,
) )
.returning( .returning(
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.returning, .returning,
@@ -933,9 +953,10 @@ pub fn render_nav(
NavUiType::Title => NavTitle::new( NavUiType::Title => NavTitle::new(
ctx.ndb, ctx.ndb,
ctx.img_cache, 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(), nav.routes(),
col, col,
ctx.i18n,
) )
.show_move_button(!narrow) .show_move_button(!narrow)
.show_delete_button(!narrow) .show_delete_button(!narrow)

View File

@@ -1,16 +1,10 @@
use enostr::{NoteId, Pubkey}; use enostr::{NoteId, Pubkey};
use notedeck::{NoteZapTargetOwned, RootNoteIdBuf, WalletType}; use notedeck::{tr, Localization, NoteZapTargetOwned, RootNoteIdBuf, WalletType};
use std::{ use std::ops::Range;
fmt::{self},
ops::Range,
};
use crate::{ use crate::{
accounts::AccountsRoute, accounts::AccountsRoute,
timeline::{ timeline::{kind::ColumnTitle, ThreadSelection, TimelineKind},
kind::{AlgoTimeline, ColumnTitle, ListKind},
ThreadSelection, TimelineKind,
},
ui::add_column::{AddAlgoRoute, AddColumnRoute}, 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 { match self {
Route::Timeline(kind) => kind.to_title(), Route::Timeline(kind) => kind.to_title(i18n),
Route::Thread(_) => ColumnTitle::simple("Thread"), Route::Thread(_) => {
Route::Reply(_id) => ColumnTitle::simple("Reply"), ColumnTitle::formatted(tr!(i18n, "Thread", "Column title for note thread view"))
Route::Quote(_id) => ColumnTitle::simple("Quote"), }
Route::Relays => ColumnTitle::simple("Relays"), 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 { Route::Accounts(amr) => match amr {
AccountsRoute::Accounts => ColumnTitle::simple("Accounts"), AccountsRoute::Accounts => ColumnTitle::formatted(tr!(
AccountsRoute::AddAccount => ColumnTitle::simple("Add Account"), 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 { 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 { AddColumnRoute::Algo(r) => match r {
AddAlgoRoute::Base => ColumnTitle::simple("Add Algo Column"), AddAlgoRoute::Base => ColumnTitle::formatted(tr!(
AddAlgoRoute::LastPerPubkey => ColumnTitle::simple("Add Last Notes Column"), 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 => { AddColumnRoute::UndecidedNotification => ColumnTitle::formatted(tr!(
ColumnTitle::simple("Add Notifications Column") i18n,
} "Add Notifications Column",
AddColumnRoute::ExternalNotification => { "Column title for adding notifications column"
ColumnTitle::simple("Add External Notifications Column") )),
} AddColumnRoute::ExternalNotification => ColumnTitle::formatted(tr!(
AddColumnRoute::Hashtag => ColumnTitle::simple("Add Hashtag Column"), i18n,
AddColumnRoute::UndecidedIndividual => { "Add External Notifications Column",
ColumnTitle::simple("Subscribe to someone's notes") "Column title for adding external notifications column"
} )),
AddColumnRoute::ExternalIndividual => { AddColumnRoute::Hashtag => ColumnTitle::formatted(tr!(
ColumnTitle::simple("Subscribe to someone else's notes") 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::Support => {
Route::NewDeck => ColumnTitle::simple("Add Deck"), ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page"))
Route::EditDeck(_) => ColumnTitle::simple("Edit Deck"), }
Route::EditProfile(_) => ColumnTitle::simple("Edit Profile"), Route::NewDeck => {
Route::Search => ColumnTitle::simple("Search"), ColumnTitle::formatted(tr!(i18n, "Add Deck", "Column title for adding new deck"))
Route::Wallet(_) => ColumnTitle::simple("Wallet"), }
Route::CustomizeZapAmount(_) => ColumnTitle::simple("Customize Zap Amount"), 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 { impl fmt::Display for Route {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Route::Timeline(kind) => match kind { Route::Timeline(kind) => match kind {
TimelineKind::List(ListKind::Contact(_pk)) => write!(f, "Home"), TimelineKind::List(ListKind::Contact(_pk)) => {
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(_))) => { write!(f, "{}", i18n, "Home", "Display name for home feed"))
write!(f, "Last Per Pubkey (Contact)") }
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::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::Thread(_) => write!(f, "{}", tr!("Thread", "Display name for thread view")),
Route::Reply(_id) => write!(f, "Reply"), Route::Reply(_id) => {
Route::Quote(_id) => write!(f, "Quote"), write!(f, "{}", tr!("Reply", "Display name for reply composition"))
Route::Relays => write!(f, "Relays"), }
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 { Route::Accounts(amr) => match amr {
AccountsRoute::Accounts => write!(f, "Accounts"), AccountsRoute::Accounts => write!(
AccountsRoute::AddAccount => write!(f, "Add Account"), 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::ComposeNote => write!(
Route::AddColumn(_) => write!(f, "Add Column"), f,
Route::Support => write!(f, "Support"), "{}",
Route::NewDeck => write!(f, "Add Deck"), tr!("Compose Note", "Display name for note composition")
Route::EditDeck(_) => write!(f, "Edit Deck"), ),
Route::EditProfile(_) => write!(f, "Edit Profile"), Route::AddColumn(_) => {
Route::Search => write!(f, "Search"), write!(f, "{}", tr!("Add Column", "Display name for adding column"))
Route::Wallet(_) => write!(f, "Wallet"), }
Route::CustomizeZapAmount(_) => write!(f, "Customize Zap Amount"), 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)] #[derive(Clone, Debug)]
pub struct SingletonRouter<R: Clone> { pub struct SingletonRouter<R: Clone> {

View File

@@ -13,7 +13,7 @@ use crate::{
Error, Error,
}; };
use notedeck::{storage, DataPath, DataPathType, Directory}; use notedeck::{storage, DataPath, DataPathType, Directory, Localization};
use tokenator::{ParseError, TokenParser, TokenWriter}; use tokenator::{ParseError, TokenParser, TokenWriter};
pub static DECKS_CACHE_FILE: &str = "decks_cache.json"; pub static DECKS_CACHE_FILE: &str = "decks_cache.json";
@@ -22,6 +22,7 @@ pub fn load_decks_cache(
path: &DataPath, path: &DataPath,
ndb: &Ndb, ndb: &Ndb,
timeline_cache: &mut TimelineCache, timeline_cache: &mut TimelineCache,
i18n: &mut Localization,
) -> Option<DecksCache> { ) -> Option<DecksCache> {
let data_path = path.path(DataPathType::Setting); 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()?; serde_json::from_str::<SerializableDecksCache>(&decks_cache_str).ok()?;
serializable_decks_cache serializable_decks_cache
.decks_cache(ndb, timeline_cache) .decks_cache(ndb, timeline_cache, i18n)
.ok() .ok()
} }
@@ -91,6 +92,7 @@ impl SerializableDecksCache {
self, self,
ndb: &Ndb, ndb: &Ndb,
timeline_cache: &mut TimelineCache, timeline_cache: &mut TimelineCache,
i18n: &mut Localization,
) -> Result<DecksCache, Error> { ) -> Result<DecksCache, Error> {
let account_to_decks = self let account_to_decks = self
.decks_cache .decks_cache
@@ -102,7 +104,7 @@ impl SerializableDecksCache {
}) })
.collect::<Result<HashMap<Pubkey, Decks>, Error>>()?; .collect::<Result<HashMap<Pubkey, Decks>, Error>>()?;
Ok(DecksCache::new(account_to_decks)) Ok(DecksCache::new(account_to_decks, i18n))
} }
} }

View File

@@ -6,11 +6,11 @@ use nostrdb::{Ndb, Transaction};
use notedeck::{ use notedeck::{
contacts::{contacts_filter, hybrid_contacts_filter}, contacts::{contacts_filter, hybrid_contacts_filter},
filter::{self, default_limit, default_remote_limit, HybridFilter}, 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 serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::{borrow::Cow, fmt::Display};
use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter};
use tracing::{error, warn}; use tracing::{error, warn};
@@ -254,20 +254,55 @@ impl AlgoTimeline {
} }
} }
/*
impl Display for TimelineKind { impl Display for TimelineKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
TimelineKind::List(ListKind::Contact(_src)) => f.write_str("Home"), TimelineKind::List(ListKind::Contact(_src)) => write!(
TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => f.write_str("Last Notes"), f,
TimelineKind::Generic(_) => f.write_str("Timeline"), "{}",
TimelineKind::Notifications(_) => f.write_str("Notifications"), tr!("Home", "Timeline kind label for contact lists")
TimelineKind::Profile(_) => f.write_str("Profile"), ),
TimelineKind::Universe => f.write_str("Universe"), TimelineKind::Algo(AlgoTimeline::LastPerPubkey(_lk)) => write!(
TimelineKind::Hashtag(_) => f.write_str("Hashtags"), f,
TimelineKind::Search(_) => f.write_str("Search"), "{}",
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 { impl TimelineKind {
pub fn pubkey(&self) -> Option<&Pubkey> { 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 { match self {
TimelineKind::Search(query) => { TimelineKind::Search(query) => {
ColumnTitle::formatted(format!("Search \"{}\"", query.search)) ColumnTitle::formatted(format!("Search \"{}\"", query.search))
} }
TimelineKind::List(list_kind) => match list_kind { 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 { 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::Profile(_pubkey_source) => ColumnTitle::needs_db(self),
TimelineKind::Universe => ColumnTitle::simple("Universe"), TimelineKind::Universe => {
TimelineKind::Generic(_) => ColumnTitle::simple("Custom"), 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()), TimelineKind::Hashtag(hashtag) => ColumnTitle::formatted(hashtag.join(" ").to_string()),
} }
} }

View File

@@ -9,8 +9,8 @@ use crate::{
use notedeck::{ use notedeck::{
contacts::hybrid_contacts_filter, contacts::hybrid_contacts_filter,
filter::{self, HybridFilter}, filter::{self, HybridFilter},
Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, NoteCache, NoteRef, tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, Localization,
UnknownIds, NoteCache, NoteRef, UnknownIds,
}; };
use egui_virtual_list::VirtualList; use egui_virtual_list::VirtualList;
@@ -64,10 +64,16 @@ pub enum ViewFilter {
} }
impl ViewFilter { impl ViewFilter {
pub fn name(&self) -> &'static str { pub fn name(&self, i18n: &mut Localization) -> String {
match self { match self {
ViewFilter::Notes => "Notes", ViewFilter::Notes => tr!(i18n, "Notes", "Filter label for notes only view"),
ViewFilter::NotesAndReplies => "Notes & Replies", 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; lim += filter.limit().unwrap_or(1) as i32;
} }
debug!("setup_initial_timeline: limit for local filter is {}", lim);
let notes: Vec<NoteRef> = ndb let notes: Vec<NoteRef> = ndb
.query(txn, filters.local(), lim)? .query(txn, filters.local(), lim)?
.into_iter() .into_iter()

View File

@@ -1,12 +1,11 @@
use crate::login_manager::AcquireKeyState; use crate::login_manager::AcquireKeyState;
use crate::ui::{Preview, PreviewConfig}; use crate::ui::{Preview, PreviewConfig};
use egui::{ use egui::{
Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextBuffer, TextEdit, Align, Button, Color32, Frame, InnerResponse, Layout, Margin, RichText, TextEdit, Vec2,
Vec2,
}; };
use egui_winit::clipboard::Clipboard; use egui_winit::clipboard::Clipboard;
use enostr::Keypair; use enostr::Keypair;
use notedeck::{fonts::get_font_size, AppAction, NotedeckTextStyle}; use notedeck::{fonts::get_font_size, tr, AppAction, Localization, NotedeckTextStyle};
use notedeck_ui::{ use notedeck_ui::{
app_images, app_images,
context_menu::{input_context, PasteBehavior}, context_menu::{input_context, PasteBehavior},
@@ -15,6 +14,7 @@ use notedeck_ui::{
pub struct AccountLoginView<'a> { pub struct AccountLoginView<'a> {
manager: &'a mut AcquireKeyState, manager: &'a mut AcquireKeyState,
clipboard: &'a mut Clipboard, clipboard: &'a mut Clipboard,
i18n: &'a mut Localization,
} }
pub enum AccountLoginResponse { pub enum AccountLoginResponse {
@@ -23,8 +23,16 @@ pub enum AccountLoginResponse {
} }
impl<'a> AccountLoginView<'a> { impl<'a> AccountLoginView<'a> {
pub fn new(manager: &'a mut AcquireKeyState, clipboard: &'a mut Clipboard) -> Self { pub fn new(
AccountLoginView { manager, clipboard } 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>> { 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(|ui| {
ui.vertical_centered(|ui| { ui.vertical_centered(|ui| {
ui.add_space(32.0); ui.add_space(32.0);
ui.label(login_title_text()); ui.label(login_title_text(self.i18n));
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(login_textedit_info_text()); ui.label(login_textedit_info_text(self.i18n));
}); });
ui.vertical_centered_justified(|ui| { ui.vertical_centered_justified(|ui| {
@@ -48,7 +56,7 @@ impl<'a> AccountLoginView<'a> {
let button_width = 32.0; let button_width = 32.0;
let text_edit_width = available_width - button_width; 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); input_context(&textedit_resp, self.clipboard, self.manager.input_buffer(), PasteBehavior::Clear);
if eye_button(ui, self.manager.password_visible()).clicked() { 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| { ui.with_layout(Layout::left_to_right(Align::TOP), |ui| {
let help_text_style = NotedeckTextStyle::Small; let help_text_style = NotedeckTextStyle::Small;
ui.add(egui::Label::new( 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()) .text_style(help_text_style.text_style())
.size(get_font_size(ui.ctx(), &help_text_style)).color(ui.visuals().weak_text_color()), .size(get_font_size(ui.ctx(), &help_text_style)).color(ui.visuals().weak_text_color()),
).wrap()) ).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(); self.manager.apply_acquire();
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( 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) .color(ui.style().visuals.noninteractive().fg_stroke.color)
.text_style(NotedeckTextStyle::Body.text_style()), .text_style(NotedeckTextStyle::Body.text_style()),
); );
if ui 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() .clicked()
{ {
self.manager.should_create_new(); self.manager.should_create_new();
@@ -98,21 +106,21 @@ impl<'a> AccountLoginView<'a> {
} }
} }
fn login_title_text() -> RichText { fn login_title_text(i18n: &mut Localization) -> RichText {
RichText::new("Login") RichText::new(tr!(i18n, "Login", "Login page title"))
.text_style(NotedeckTextStyle::Heading2.text_style()) .text_style(NotedeckTextStyle::Heading2.text_style())
.strong() .strong()
} }
fn login_textedit_info_text() -> RichText { fn login_textedit_info_text(i18n: &mut Localization) -> RichText {
RichText::new("Enter your key") 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() .strong()
.text_style(NotedeckTextStyle::Body.text_style()) .text_style(NotedeckTextStyle::Body.text_style())
} }
fn login_button() -> Button<'static> { fn login_button(i18n: &mut Localization) -> Button<'static> {
Button::new( 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()) .text_style(NotedeckTextStyle::Body.text_style())
.strong(), .strong(),
) )
@@ -120,11 +128,19 @@ fn login_button() -> Button<'static> {
.min_size(Vec2::new(0.0, 40.0)) .min_size(Vec2::new(0.0, 40.0))
} }
fn login_textedit(manager: &mut AcquireKeyState) -> TextEdit { fn login_textedit<'a>(
let create_textedit: fn(&mut dyn TextBuffer) -> TextEdit = |text| { manager: &'a mut AcquireKeyState,
i18n: &'a mut Localization,
) -> TextEdit<'a> {
let create_textedit = |text| {
egui::TextEdit::singleline(text) egui::TextEdit::singleline(text)
.hint_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) .vertical_align(Align::Center)
.min_size(Vec2::new(0.0, 40.0)) .min_size(Vec2::new(0.0, 40.0))
@@ -163,7 +179,7 @@ mod preview {
impl App for AccountLoginPreview { impl App for AccountLoginPreview {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { 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 None
} }

View File

@@ -3,16 +3,17 @@ use egui::{
}; };
use enostr::Pubkey; use enostr::Pubkey;
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::{Accounts, Images}; use notedeck::{tr, Accounts, Images, Localization};
use notedeck_ui::colors::PINK; use notedeck_ui::colors::PINK;
use notedeck_ui::profile::preview::SimpleProfilePreview;
use notedeck_ui::app_images; use notedeck_ui::app_images;
use notedeck_ui::profile::preview::SimpleProfilePreview;
pub struct AccountsView<'a> { pub struct AccountsView<'a> {
ndb: &'a Ndb, ndb: &'a Ndb,
accounts: &'a Accounts, accounts: &'a Accounts,
img_cache: &'a mut Images, img_cache: &'a mut Images,
i18n: &'a mut Localization,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -29,24 +30,30 @@ enum ProfilePreviewAction {
} }
impl<'a> AccountsView<'a> { 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 { AccountsView {
ndb, ndb,
accounts, accounts,
img_cache, img_cache,
i18n,
} }
} }
pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<AccountsViewResponse>> { pub fn ui(&mut self, ui: &mut Ui) -> InnerResponse<Option<AccountsViewResponse>> {
Frame::new().outer_margin(12.0).show(ui, |ui| { 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); return Some(resp);
} }
ui.add_space(8.0); ui.add_space(8.0);
scroll_area() scroll_area()
.show(ui, |ui| { .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 .inner
}) })
@@ -57,6 +64,7 @@ impl<'a> AccountsView<'a> {
accounts: &Accounts, accounts: &Accounts,
ndb: &Ndb, ndb: &Ndb,
img_cache: &mut Images, img_cache: &mut Images,
i18n: &mut Localization,
) -> Option<AccountsViewResponse> { ) -> Option<AccountsViewResponse> {
let mut return_op: Option<AccountsViewResponse> = None; let mut return_op: Option<AccountsViewResponse> = None;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
@@ -79,8 +87,12 @@ impl<'a> AccountsView<'a> {
let max_size = egui::vec2(ui.available_width(), 77.0); let max_size = egui::vec2(ui.available_width(), 77.0);
let resp = ui.allocate_response(max_size, egui::Sense::click()); let resp = ui.allocate_response(max_size, egui::Sense::click());
ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| { ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| {
let preview = let preview = SimpleProfilePreview::new(
SimpleProfilePreview::new(profile.as_ref(), img_cache, has_nsec); profile.as_ref(),
img_cache,
i18n,
has_nsec,
);
show_profile_card(ui, preview, max_size, is_selected, resp) show_profile_card(ui, preview, max_size, is_selected, resp)
}) })
.inner .inner
@@ -104,12 +116,13 @@ impl<'a> AccountsView<'a> {
fn top_section_buttons_widget( fn top_section_buttons_widget(
ui: &mut egui::Ui, ui: &mut egui::Ui,
i18n: &mut Localization,
) -> InnerResponse<Option<AccountsViewResponse>> { ) -> InnerResponse<Option<AccountsViewResponse>> {
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
Vec2::new(ui.available_size_before_wrap().x, 32.0), Vec2::new(ui.available_size_before_wrap().x, 32.0),
Layout::left_to_right(egui::Align::Center), Layout::left_to_right(egui::Align::Center),
|ui| { |ui| {
if ui.add(add_account_button()).clicked() { if ui.add(add_account_button(i18n)).clicked() {
Some(AccountsViewResponse::RouteToLogin) Some(AccountsViewResponse::RouteToLogin)
} else { } else {
None None
@@ -141,16 +154,14 @@ fn show_profile_card(
.inner_margin(8.0) .inner_margin(8.0)
.show(ui, |ui| { .show(ui, |ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let btn = sign_out_button(preview.i18n);
ui.add(preview); ui.add(preview);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if card_resp.clicked() { if card_resp.clicked() {
op = Some(ProfilePreviewAction::SwitchTo); op = Some(ProfilePreviewAction::SwitchTo);
} }
if ui if ui.add_sized(egui::Vec2::new(84.0, 32.0), btn).clicked() {
.add_sized(egui::Vec2::new(84.0, 32.0), sign_out_button())
.clicked()
{
op = Some(ProfilePreviewAction::RemoveAccount) op = Some(ProfilePreviewAction::RemoveAccount)
} }
}); });
@@ -168,10 +179,14 @@ fn scroll_area() -> ScrollArea {
.auto_shrink([false; 2]) .auto_shrink([false; 2])
} }
fn add_account_button() -> Button<'static> { fn add_account_button(i18n: &mut Localization) -> Button<'static> {
Button::image_and_text( Button::image_and_text(
app_images::add_account_image().fit_to_exact_size(Vec2::new(48.0, 48.0)), app_images::add_account_image().fit_to_exact_size(Vec2::new(48.0, 48.0)),
RichText::new(" Add account") RichText::new(tr!(
i18n,
"Add account",
"Button label to add a new account"
))
.size(16.0) .size(16.0)
// TODO: this color should not be hard coded. Find some way to add it to the visuals // TODO: this color should not be hard coded. Find some way to add it to the visuals
.color(PINK), .color(PINK),
@@ -179,6 +194,10 @@ fn add_account_button() -> Button<'static> {
.frame(false) .frame(false)
} }
fn sign_out_button() -> egui::Button<'static> { fn sign_out_button(i18n: &mut Localization) -> egui::Button<'static> {
egui::Button::new(RichText::new("Sign out")) egui::Button::new(RichText::new(tr!(
i18n,
"Sign out",
"Button label to sign out of account"
)))
} }

View File

@@ -17,7 +17,7 @@ use crate::{
Damus, 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 notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images};
use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter};
@@ -167,6 +167,7 @@ pub struct AddColumnView<'a> {
ndb: &'a Ndb, ndb: &'a Ndb,
img_cache: &'a mut Images, img_cache: &'a mut Images,
cur_account: &'a UserAccount, cur_account: &'a UserAccount,
i18n: &'a mut Localization,
} }
impl<'a> AddColumnView<'a> { impl<'a> AddColumnView<'a> {
@@ -175,12 +176,14 @@ impl<'a> AddColumnView<'a> {
ndb: &'a Ndb, ndb: &'a Ndb,
img_cache: &'a mut Images, img_cache: &'a mut Images,
cur_account: &'a UserAccount, cur_account: &'a UserAccount,
i18n: &'a mut Localization,
) -> Self { ) -> Self {
Self { Self {
key_state_map, key_state_map,
ndb, ndb,
img_cache, img_cache,
cur_account, cur_account,
i18n,
} }
} }
@@ -229,8 +232,12 @@ impl<'a> AddColumnView<'a> {
deck_author: Pubkey, deck_author: Pubkey,
) -> Option<AddColumnResponse> { ) -> Option<AddColumnResponse> {
let algo_option = ColumnOptionData { let algo_option = ColumnOptionData {
title: "Contact List", title: tr!(self.i18n, "Contact List", "Title for contact list column"),
description: "Source the last note for each user in your contact list", 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(), icon: app_images::home_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided( option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided(
ListKind::contact_list(deck_author), ListKind::contact_list(deck_author),
@@ -245,8 +252,16 @@ impl<'a> AddColumnView<'a> {
fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
let algo_option = ColumnOptionData { let algo_option = ColumnOptionData {
title: "Last Note per User", title: tr!(
description: "Show the last note for each user from a list", 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(), icon: app_images::algo_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
}; };
@@ -291,7 +306,11 @@ impl<'a> AddColumnView<'a> {
let text_edit = key_state.get_acquire_textedit(|text| { let text_edit = key_state.get_acquire_textedit(|text| {
egui::TextEdit::singleline(text) egui::TextEdit::singleline(text)
.hint_text( .hint_text(
RichText::new("Enter the user's key (npub, hex, nip05) here...") 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()), .text_style(NotedeckTextStyle::Body.text_style()),
) )
.vertical_align(Align::Center) .vertical_align(Align::Center)
@@ -303,9 +322,11 @@ impl<'a> AddColumnView<'a> {
ui.add(text_edit); ui.add(text_edit);
key_state.handle_input_change_after_acquire(); 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(); 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() .clicked()
.then(|| to_option(keypair.pubkey).take_as_response(self.cur_account)) .then(|| to_option(keypair.pubkey).take_as_response(self.cur_account))
} else { } else {
@@ -386,7 +407,8 @@ impl<'a> AddColumnView<'a> {
title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding) 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 animation_rect = helper.get_animation_rect();
let cur_icon_width = helper.scale_1d_pos(min_icon_width); let cur_icon_width = helper.scale_1d_pos(min_icon_width);
@@ -442,11 +464,15 @@ impl<'a> AddColumnView<'a> {
helper.take_animation_response() 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(); let mut vec = Vec::new();
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Home", title: tr!(self.i18n, "Home", "Title for Home column"),
description: "See notes from your contacts", description: tr!(
self.i18n,
"See notes from your contacts",
"Description for Home column"
),
icon: app_images::home_image(), icon: app_images::home_image(),
option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() { option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() {
PubkeySource::DeckAuthor PubkeySource::DeckAuthor
@@ -455,32 +481,52 @@ impl<'a> AddColumnView<'a> {
}), }),
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Notifications", title: tr!(self.i18n, "Notifications", "Title for notifications column"),
description: "Stay up to date with notifications and mentions", 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), icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::UndecidedNotification, option: AddColumnOption::UndecidedNotification,
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Universe", title: tr!(self.i18n, "Universe", "Title for universe column"),
description: "See the whole nostr universe", description: tr!(
self.i18n,
"See the whole nostr universe",
"Description for universe column"
),
icon: app_images::universe_image(), icon: app_images::universe_image(),
option: AddColumnOption::Universe, option: AddColumnOption::Universe,
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Hashtags", title: tr!(self.i18n, "Hashtags", "Title for hashtags column"),
description: "Stay up to date with a certain hashtag", description: tr!(
self.i18n,
"Stay up to date with a certain hashtag",
"Description for hashtags column"
),
icon: app_images::hashtag_image(), icon: app_images::hashtag_image(),
option: AddColumnOption::UndecidedHashtag, option: AddColumnOption::UndecidedHashtag,
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Individual", title: tr!(self.i18n, "Individual", "Title for individual user column"),
description: "Stay up to date with someone's notes & replies", description: tr!(
self.i18n,
"Stay up to date with someone's notes & replies",
"Description for individual user column"
),
icon: app_images::profile_image(), icon: app_images::profile_image(),
option: AddColumnOption::UndecidedIndividual, option: AddColumnOption::UndecidedIndividual,
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Algo", title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"),
description: "Algorithmic feeds to aid in note discovery", description: tr!(
self.i18n,
"Algorithmic feeds to aid in note discovery",
"Description for algorithmic feeds column"
),
icon: app_images::algo_image(), icon: app_images::algo_image(),
option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)), option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
}); });
@@ -488,7 +534,7 @@ impl<'a> AddColumnView<'a> {
vec 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 mut vec = Vec::new();
let source = if self.cur_account.key.secret_key.is_some() { let source = if self.cur_account.key.secret_key.is_some() {
@@ -498,15 +544,31 @@ impl<'a> AddColumnView<'a> {
}; };
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Your Notifications", title: tr!(
description: "Stay up to date with your notifications and mentions", 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), icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::Notification(source), option: AddColumnOption::Notification(source),
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Someone else's Notifications", title: tr!(
description: "Stay up to date with someone else's notifications and mentions", 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), icon: app_images::notifications_image(ui.visuals().dark_mode),
option: AddColumnOption::ExternalNotification, option: AddColumnOption::ExternalNotification,
}); });
@@ -514,7 +576,7 @@ impl<'a> AddColumnView<'a> {
vec vec
} }
fn get_individual_options(&self) -> Vec<ColumnOptionData> { fn get_individual_options(&mut self) -> Vec<ColumnOptionData> {
let mut vec = Vec::new(); let mut vec = Vec::new();
let source = if self.cur_account.key.secret_key.is_some() { let source = if self.cur_account.key.secret_key.is_some() {
@@ -524,15 +586,27 @@ impl<'a> AddColumnView<'a> {
}; };
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Your Notes", title: tr!(self.i18n, "Your Notes", "Title for your notes column"),
description: "Keep track of your notes & replies", description: tr!(
self.i18n,
"Keep track of your notes & replies",
"Description for your notes column"
),
icon: app_images::profile_image(), icon: app_images::profile_image(),
option: AddColumnOption::Individual(source), option: AddColumnOption::Individual(source),
}); });
vec.push(ColumnOptionData { vec.push(ColumnOptionData {
title: "Someone else's Notes", title: tr!(
description: "Stay up to date with someone else's notes & replies", 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(), icon: app_images::profile_image(),
option: AddColumnOption::ExternalIndividual, option: AddColumnOption::ExternalIndividual,
}); });
@@ -541,12 +615,16 @@ impl<'a> AddColumnView<'a> {
} }
} }
fn find_user_button() -> impl Widget { fn find_user_button(i18n: &mut Localization) -> impl Widget {
styled_button("Find User", notedeck_ui::colors::PINK) 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 { fn add_column_button(i18n: &mut Localization) -> impl Widget {
styled_button("Add", notedeck_ui::colors::PINK) 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 { struct ColumnOptionData {
title: &'static str, title: String,
description: &'static str, description: String,
icon: Image<'static>, icon: Image<'static>,
option: AddColumnOption, option: AddColumnOption,
} }
@@ -589,6 +667,7 @@ pub fn render_add_column_routes(
ctx.ndb, ctx.ndb,
ctx.img_cache, ctx.img_cache,
ctx.accounts.get_selected_account(), ctx.accounts.get_selected_account(),
ctx.i18n,
); );
let resp = match route { let resp = match route {
AddColumnRoute::Base => add_column_view.ui(ui), 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::UndecidedNotification => add_column_view.notifications_ui(ui),
AddColumnRoute::ExternalNotification => add_column_view.external_notification_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::UndecidedIndividual => add_column_view.individual_ui(ui),
AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui), AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui),
}; };
@@ -627,7 +706,7 @@ pub fn render_add_column_routes(
ctx.accounts, ctx.accounts,
); );
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to_replaced(Route::timeline(timeline.kind.clone())); .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 // If we are undecided, we simply route to the LastPerPubkey
// algo route selection // algo route selection
AlgoOption::LastPerPubkey(Decision::Undecided) => { AlgoOption::LastPerPubkey(Decision::Undecided) => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(Route::AddColumn(AddColumnRoute::Algo( .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 // 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 // add it to our list of timelines
AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => { AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => {
let txn = Transaction::new(ctx.ndb).unwrap(); let txn = Transaction::new(ctx.ndb).unwrap();
@@ -667,7 +746,7 @@ pub fn render_add_column_routes(
ctx.accounts, ctx.accounts,
); );
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to_replaced(Route::timeline(timeline.kind.clone())); .route_to_replaced(Route::timeline(timeline.kind.clone()));
@@ -685,13 +764,13 @@ pub fn render_add_column_routes(
}, },
AddColumnResponse::UndecidedNotification => { AddColumnResponse::UndecidedNotification => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification)); .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification));
} }
AddColumnResponse::ExternalNotification => { AddColumnResponse::ExternalNotification => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(crate::route::Route::AddColumn( .route_to(crate::route::Route::AddColumn(
@@ -699,13 +778,13 @@ pub fn render_add_column_routes(
)); ));
} }
AddColumnResponse::Hashtag => { AddColumnResponse::Hashtag => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag));
} }
AddColumnResponse::UndecidedIndividual => { AddColumnResponse::UndecidedIndividual => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(crate::route::Route::AddColumn( .route_to(crate::route::Route::AddColumn(
@@ -713,7 +792,7 @@ pub fn render_add_column_routes(
)); ));
} }
AddColumnResponse::ExternalIndividual => { AddColumnResponse::ExternalIndividual => {
app.columns_mut(ctx.accounts) app.columns_mut(ctx.i18n, ctx.accounts)
.column_mut(col) .column_mut(col)
.router_mut() .router_mut()
.route_to(crate::route::Route::AddColumn( .route_to(crate::route::Route::AddColumn(
@@ -726,6 +805,7 @@ pub fn render_add_column_routes(
pub fn hashtag_ui( pub fn hashtag_ui(
ui: &mut Ui, ui: &mut Ui,
i18n: &mut Localization,
id_string_map: &mut HashMap<Id, String>, id_string_map: &mut HashMap<Id, String>,
) -> Option<AddColumnResponse> { ) -> Option<AddColumnResponse> {
padding(16.0, ui, |ui| { padding(16.0, ui, |ui| {
@@ -734,7 +814,11 @@ pub fn hashtag_ui(
let text_edit = egui::TextEdit::singleline(text_buffer) let text_edit = egui::TextEdit::singleline(text_buffer)
.hint_text( .hint_text(
RichText::new("Enter the desired hashtags here (for multiple space-separated)") RichText::new(tr!(
i18n,
"Enter the desired hashtags here (for multiple space-separated)",
"Placeholder for hashtag input field"
))
.text_style(NotedeckTextStyle::Body.text_style()), .text_style(NotedeckTextStyle::Body.text_style()),
) )
.vertical_align(Align::Center) .vertical_align(Align::Center)
@@ -748,7 +832,7 @@ pub fn hashtag_ui(
let mut handle_user_input = false; let mut handle_user_input = false;
if ui.input(|i| i.key_released(egui::Key::Enter)) if ui.input(|i| i.key_released(egui::Key::Enter))
|| ui || 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() .clicked()
{ {
handle_user_input = true; handle_user_input = true;
@@ -790,7 +874,7 @@ mod tests {
let data_str = "column:algo_selection:last_per_pubkey"; let data_str = "column:algo_selection:last_per_pubkey";
let data = &data_str.split(":").collect::<Vec<&str>>(); let data = &data_str.split(":").collect::<Vec<&str>>();
let mut token_writer = TokenWriter::default(); 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 parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap();
let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey); let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey);
parsed.serialize_tokens(&mut token_writer); parsed.serialize_tokens(&mut token_writer);

View File

@@ -12,7 +12,8 @@ use crate::{
use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder}; use egui::{Margin, Response, RichText, Sense, Stroke, UiBuilder};
use enostr::Pubkey; use enostr::Pubkey;
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::{Images, NotedeckTextStyle}; use notedeck::tr;
use notedeck::{Images, Localization, NotedeckTextStyle};
use notedeck_ui::app_images; use notedeck_ui::app_images;
use notedeck_ui::{ use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
@@ -26,6 +27,7 @@ pub struct NavTitle<'a> {
routes: &'a [Route], routes: &'a [Route],
col_id: usize, col_id: usize,
options: u32, options: u32,
i18n: &'a mut Localization,
} }
impl<'a> NavTitle<'a> { impl<'a> NavTitle<'a> {
@@ -39,6 +41,7 @@ impl<'a> NavTitle<'a> {
columns: &'a Columns, columns: &'a Columns,
routes: &'a [Route], routes: &'a [Route],
col_id: usize, col_id: usize,
i18n: &'a mut Localization,
) -> Self { ) -> Self {
let options = Self::SHOW_MOVE | Self::SHOW_DELETE; let options = Self::SHOW_MOVE | Self::SHOW_DELETE;
NavTitle { NavTitle {
@@ -48,6 +51,7 @@ impl<'a> NavTitle<'a> {
routes, routes,
col_id, col_id,
options, options,
i18n,
} }
} }
@@ -128,7 +132,7 @@ impl<'a> NavTitle<'a> {
// NOTE(jb55): include graphic in back label as well because why // NOTE(jb55): include graphic in back label as well because why
// not it looks cool // not it looks cool
let pfp_resp = self.title_pfp(ui, prev, 32.0); 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 { let back_resp = match &column_title {
ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)), ColumnTitle::Simple(title) => ui.add(Self::back_label(title, color)),
@@ -181,7 +185,7 @@ impl<'a> NavTitle<'a> {
animation_resp 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 id = ui.id().with("title");
let delete_button_resp = self.delete_column_button(ui, 32.0); 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)) { if ui.data_mut(|d| *d.get_temp_mut_or_default(id)) {
let mut confirm_pressed = false; let mut confirm_pressed = false;
delete_button_resp.show_tooltip_ui(|ui| { 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() { if confirm_resp.clicked() {
confirm_pressed = true; 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)); ui.data_mut(|d| d.insert_temp(id, false));
} }
}); });
@@ -206,7 +218,11 @@ impl<'a> NavTitle<'a> {
} }
confirm_pressed confirm_pressed
} else { } 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 false
} }
} }
@@ -220,7 +236,11 @@ impl<'a> NavTitle<'a> {
// showing the hover text while showing the move tooltip causes some weird visuals // 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()) { 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() { if move_resp.clicked() {
@@ -513,8 +533,8 @@ impl<'a> NavTitle<'a> {
.selectable(false) .selectable(false)
} }
fn title_label(&self, ui: &mut egui::Ui, top: &Route) { fn title_label(&mut self, ui: &mut egui::Ui, top: &Route) {
let column_title = top.title(); let column_title = top.title(self.i18n);
match &column_title { match &column_title {
ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)), ColumnTitle::Simple(title) => ui.add(Self::title_label_value(title)),

View File

@@ -1,5 +1,6 @@
use crate::{app_style::deck_icon_font_sized, deck_state::DeckState}; use crate::{app_style::deck_icon_font_sized, deck_state::DeckState};
use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget}; use egui::{vec2, Button, Color32, Label, RichText, Stroke, Ui, Widget};
use notedeck::{tr, Localization};
use notedeck::{NamedFontFamily, NotedeckTextStyle}; use notedeck::{NamedFontFamily, NotedeckTextStyle};
use notedeck_ui::{ use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
@@ -10,6 +11,7 @@ use notedeck_ui::{
pub struct ConfigureDeckView<'a> { pub struct ConfigureDeckView<'a> {
state: &'a mut DeckState, state: &'a mut DeckState,
create_button_text: String, create_button_text: String,
pub i18n: &'a mut Localization,
} }
pub struct ConfigureDeckResponse { pub struct ConfigureDeckResponse {
@@ -17,18 +19,17 @@ pub struct ConfigureDeckResponse {
pub name: String, pub name: String,
} }
static CREATE_TEXT: &str = "Create Deck";
impl<'a> ConfigureDeckView<'a> { 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 { Self {
state, 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 { pub fn with_create_text(mut self, text: String) -> Self {
self.create_button_text = text.to_owned(); self.create_button_text = text;
self self
} }
@@ -39,13 +40,22 @@ impl<'a> ConfigureDeckView<'a> {
); );
padding(16.0, ui, |ui| { padding(16.0, ui, |ui| {
ui.add(Label::new( 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.add_space(8.0);
ui.text_edit_singleline(&mut self.state.deck_name); ui.text_edit_singleline(&mut self.state.deck_name);
ui.add_space(8.0); ui.add_space(8.0);
ui.add(Label::new( ui.add(Label::new(
RichText::new("We recommend short names") RichText::new(tr!(
self.i18n,
"We recommend short names",
"Hint for deck name input field"
))
.color(ui.visuals().noninteractive().fg_stroke.color) .color(ui.visuals().noninteractive().fg_stroke.color)
.size(notedeck::fonts::get_font_size( .size(notedeck::fonts::get_font_size(
ui.ctx(), ui.ctx(),
@@ -54,7 +64,10 @@ impl<'a> ConfigureDeckView<'a> {
)); ));
ui.add_space(32.0); 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 if ui
.add(deck_icon( .add(deck_icon(
@@ -92,7 +105,12 @@ impl<'a> ConfigureDeckView<'a> {
self.state.warn_no_title = false; 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; let mut resp = None;
if ui if ui
@@ -120,29 +138,31 @@ impl<'a> ConfigureDeckView<'a> {
} }
} }
fn show_warnings(ui: &mut Ui, warn_no_icon: bool, warn_no_title: bool) { fn show_warnings(ui: &mut Ui, i18n: &mut Localization, warn_no_icon: bool, warn_no_title: bool) {
if warn_no_icon || warn_no_title { let warning = if warn_no_title && warn_no_icon {
let messages = [ tr!(
if warn_no_title { i18n,
"create a name for the deck" "Please create a name for the deck and select an icon.",
} else { "Error message for missing deck name and icon"
""
},
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(), } 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 { impl App for ConfigureDeckPreview {
fn update( fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
&mut self, ConfigureDeckView::new(&mut self.state, ctx.i18n).ui(ui);
_app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> Option<AppAction> {
ConfigureDeckView::new(&mut self.state).ui(ui);
None None
} }

View File

@@ -3,22 +3,22 @@ use egui::Widget;
use crate::deck_state::DeckState; use crate::deck_state::DeckState;
use super::configure_deck::{ConfigureDeckResponse, ConfigureDeckView}; use super::configure_deck::{ConfigureDeckResponse, ConfigureDeckView};
use notedeck::{tr, Localization};
use notedeck_ui::padding; use notedeck_ui::padding;
pub struct EditDeckView<'a> { pub struct EditDeckView<'a> {
config_view: ConfigureDeckView<'a>, config_view: ConfigureDeckView<'a>,
} }
static EDIT_TEXT: &str = "Edit Deck";
pub enum EditDeckResponse { pub enum EditDeckResponse {
Edit(ConfigureDeckResponse), Edit(ConfigureDeckResponse),
Delete, Delete,
} }
impl<'a> EditDeckView<'a> { impl<'a> EditDeckView<'a> {
pub fn new(state: &'a mut DeckState) -> Self { pub fn new(state: &'a mut DeckState, i18n: &'a mut Localization) -> Self {
let config_view = ConfigureDeckView::new(state).with_create_text(EDIT_TEXT); 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 } Self { config_view }
} }
@@ -26,7 +26,7 @@ impl<'a> EditDeckView<'a> {
let mut edit_deck_resp = None; let mut edit_deck_resp = None;
padding(egui::Margin::symmetric(16, 4), ui, |ui| { 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); 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| { |ui: &mut egui::Ui| {
let size = egui::vec2(108.0, 40.0); let size = egui::vec2(108.0, 40.0);
ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| { ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Center), |ui| {
ui.add( 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) .fill(ui.visuals().error_fg_color)
.min_size(size), .min_size(size),
) )
@@ -75,12 +75,8 @@ mod preview {
} }
impl App for EditDeckPreview { impl App for EditDeckPreview {
fn update( fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
&mut self, EditDeckView::new(&mut self.state, ctx.i18n).ui(ui);
_app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> Option<AppAction> {
EditDeckView::new(&mut self.state).ui(ui);
None None
} }
} }

View File

@@ -1,5 +1,3 @@
use std::fmt::Display;
use egui::{ use egui::{
emath::GuiRounding, pos2, vec2, Color32, CornerRadius, FontId, Frame, Label, Layout, Slider, emath::GuiRounding, pos2, vec2, Color32, CornerRadius, FontId, Frame, Label, Layout, Slider,
Stroke, Stroke,
@@ -7,7 +5,8 @@ use egui::{
use enostr::Pubkey; use enostr::Pubkey;
use nostrdb::{Ndb, ProfileRecord, Transaction}; use nostrdb::{Ndb, ProfileRecord, Transaction};
use notedeck::{ 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::{ use notedeck_ui::{
app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable, app_images, colors, profile::display_name_widget, widgets::styled_button_toggleable,
@@ -20,11 +19,13 @@ pub struct CustomZapView<'a> {
txn: &'a Transaction, txn: &'a Transaction,
target_pubkey: &'a Pubkey, target_pubkey: &'a Pubkey,
default_msats: u64, default_msats: u64,
i18n: &'a mut Localization,
} }
#[allow(clippy::new_without_default)] #[allow(clippy::new_without_default)]
impl<'a> CustomZapView<'a> { impl<'a> CustomZapView<'a> {
pub fn new( pub fn new(
i18n: &'a mut Localization,
images: &'a mut Images, images: &'a mut Images,
ndb: &'a Ndb, ndb: &'a Ndb,
txn: &'a Transaction, txn: &'a Transaction,
@@ -37,6 +38,7 @@ impl<'a> CustomZapView<'a> {
ndb, ndb,
txn, txn,
default_msats, default_msats,
i18n,
} }
} }
@@ -48,7 +50,7 @@ impl<'a> CustomZapView<'a> {
} }
fn ui_internal(&mut self, ui: &mut egui::Ui) -> Option<u64> { fn ui_internal(&mut self, ui: &mut egui::Ui) -> Option<u64> {
show_title(ui); show_title(ui, self.i18n);
ui.add_space(16.0); ui.add_space(16.0);
@@ -82,7 +84,7 @@ impl<'a> CustomZapView<'a> {
} else { } else {
(self.default_msats / 1000).to_string() (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 mut maybe_sats = cur_amount.parse::<u64>().ok();
let prev_slider_sats = maybe_sats.unwrap_or(default_sats).clamp(1, 100000); 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); 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(); cur_amount = selection.to_string();
maybe_sats = Some(selection); maybe_sats = Some(selection);
} }
@@ -110,7 +112,7 @@ impl<'a> CustomZapView<'a> {
ui.data_mut(|d| d.insert_temp(id, cur_amount)); ui.data_mut(|d| d.insert_temp(id, cur_amount));
let resp = ui.add(styled_button_toggleable( let resp = ui.add(styled_button_toggleable(
"Send", &tr!(self.i18n, "Send", "Button label to send a zap"),
colors::PINK, colors::PINK,
is_valid_zap(maybe_sats), is_valid_zap(maybe_sats),
)); ));
@@ -129,7 +131,7 @@ fn is_valid_zap(amount: Option<u64>) -> bool {
amount.is_some_and(|sats| sats > 0) 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; let max_size = 32.0;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
vec2(ui.available_width(), max_size), vec2(ui.available_width(), max_size),
@@ -158,7 +160,8 @@ fn show_title(ui: &mut egui::Ui) {
ui.add_space(8.0); ui.add_space(8.0);
ui.add(egui::Label::new( 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_font = NotedeckTextStyle::Heading.get_bolded_font(ui.ctx());
let user_input_id = id.with("sats_amount"); 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 painter = ui.painter();
let sats_galley = painter.layout_no_wrap( 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()), NotedeckTextStyle::Heading4.get_font_id(ui.ctx()),
ui.visuals().noninteractive().text_color(), 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); .font(user_input_font);
let amount_resp = ui.add(Label::new( 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()) .text_style(NotedeckTextStyle::Heading3.text_style())
.color(ui.visuals().noninteractive().text_color()), .color(ui.visuals().noninteractive().text_color()),
)); ));
@@ -296,7 +309,11 @@ const SELECTION_BUTTONS: [ZapSelectionButton; 8] = [
ZapSelectionButton::Eighth, 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; let mut our_selection = None;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
vec2(224.0, 116.0), 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); ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
for button in SELECTION_BUTTONS { 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, ui: &mut egui::Ui,
sats_selection: Option<u64>, sats_selection: Option<u64>,
button: ZapSelectionButton, button: ZapSelectionButton,
i18n: &mut Localization,
) -> Option<u64> { ) -> Option<u64> {
let (rect, _) = ui.allocate_exact_size(vec2(50.0, 50.0), egui::Sense::click()); 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); let helper = AnimationHelper::new_from_rect(ui, ("zap_selection_button", &button), rect);
@@ -349,7 +368,11 @@ fn show_selection_button(
NotedeckTextStyle::Body.font_family(), 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 text_rect = {
let mut galley_rect = galley.rect; let mut galley_rect = galley.rect;
galley_rect.set_center(rect.center()); galley_rect.set_center(rect.center());
@@ -390,19 +413,17 @@ impl ZapSelectionButton {
ZapSelectionButton::Eighth => 100_000, ZapSelectionButton::Eighth => 100_000,
} }
} }
}
impl Display for ZapSelectionButton { pub fn to_desc_string(&self, i18n: &mut Localization) -> String {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
ZapSelectionButton::First => write!(f, "69"), ZapSelectionButton::First => "69".to_string(),
ZapSelectionButton::Second => write!(f, "100"), ZapSelectionButton::Second => "100".to_string(),
ZapSelectionButton::Third => write!(f, "420"), ZapSelectionButton::Third => "420".to_string(),
ZapSelectionButton::Fourth => write!(f, "5K"), 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 => write!(f, "10K"), 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 => write!(f, "20K"), 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 => write!(f, "50K"), 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 => write!(f, "100K"), ZapSelectionButton::Eighth => tr!(i18n, "100K", "Zap amount button for 100000 sats. Abbreviated because the button is too small to display the full amount."),
} }
} }
} }

View File

@@ -25,7 +25,9 @@ use notedeck_ui::{
NoteOptions, ProfilePic, 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; use tracing::error;
pub struct PostView<'a, 'd> { pub struct PostView<'a, 'd> {
@@ -180,7 +182,14 @@ impl<'a, 'd> PostView<'a, 'd> {
}; };
let textedit = TextEdit::multiline(&mut self.draft.buffer) 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) .frame(false)
.desired_width(ui.available_width()) .desired_width(ui.available_width())
.layouter(&mut layouter); .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| { ui.with_layout(egui::Layout::right_to_left(egui::Align::BOTTOM), |ui| {
let post_button_clicked = 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(); .clicked();
let shortcut_pressed = ui.input(|i| { 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| { 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 { if interactive {
ui.add(button) ui.add(button)
} else { } else {
@@ -798,6 +810,7 @@ mod preview {
unknown_ids: app.unknown_ids, unknown_ids: app.unknown_ids,
current_account_has_wallet: false, current_account_has_wallet: false,
clipboard: app.clipboard, clipboard: app.clipboard,
i18n: app.i18n,
}; };
PostView::new( PostView::new(

View File

@@ -2,17 +2,26 @@ use core::f32;
use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit}; use egui::{vec2, Button, CornerRadius, Layout, Margin, RichText, ScrollArea, TextEdit};
use enostr::ProfileState; 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}; use notedeck_ui::{profile::banner, ProfilePic};
pub struct EditProfileView<'a> { pub struct EditProfileView<'a> {
state: &'a mut ProfileState, state: &'a mut ProfileState,
img_cache: &'a mut Images, img_cache: &'a mut Images,
i18n: &'a mut Localization,
} }
impl<'a> EditProfileView<'a> { impl<'a> EditProfileView<'a> {
pub fn new(state: &'a mut ProfileState, img_cache: &'a mut Images) -> Self { pub fn new(
Self { state, img_cache } i18n: &'a mut Localization,
state: &'a mut ProfileState,
img_cache: &'a mut Images,
) -> Self {
Self {
i18n,
state,
img_cache,
}
} }
// return true to save // return true to save
@@ -32,7 +41,18 @@ impl<'a> EditProfileView<'a> {
notedeck_ui::padding(padding, ui, |ui| { notedeck_ui::padding(padding, ui, |ui| {
ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| {
if 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() .clicked()
{ {
save = true; save = true;
@@ -62,42 +82,78 @@ impl<'a> EditProfileView<'a> {
); );
in_frame(ui, |ui| { 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"))); ui.add(singleline_textedit(self.state.str_mut("display_name")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(singleline_textedit(self.state.str_mut("name")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(multiline_textedit(self.state.str_mut("picture")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(multiline_textedit(self.state.str_mut("banner")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(multiline_textedit(self.state.str_mut("about")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(singleline_textedit(self.state.str_mut("website")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(multiline_textedit(self.state.str_mut("lud16")));
}); });
in_frame(ui, |ui| { 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"))); ui.add(singleline_textedit(self.state.str_mut("nip05")));
let Some(nip05) = self.state.nip05() else { let Some(nip05) = self.state.nip05() else {
@@ -121,9 +177,20 @@ impl<'a> EditProfileView<'a> {
ui.colored_label( ui.colored_label(
ui.visuals().noninteractive().fg_stroke.color, ui.visuals().noninteractive().fg_stroke.color,
RichText::new(if use_domain { 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 { } 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
)
}), }),
); );
}); });

View File

@@ -4,6 +4,7 @@ pub use edit::EditProfileView;
use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke}; use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke};
use enostr::Pubkey; use enostr::Pubkey;
use nostrdb::{ProfileRecord, Transaction}; use nostrdb::{ProfileRecord, Transaction};
use notedeck::{tr, Localization};
use notedeck_ui::profile::follow_button; use notedeck_ui::profile::follow_button;
use tracing::error; use tracing::error;
@@ -90,8 +91,12 @@ impl<'a, 'd> ProfileView<'a, 'd> {
) )
.get_ptr(); .get_ptr();
profile_timeline.selected_view = profile_timeline.selected_view = tabs_ui(
tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views); ui,
self.note_context.i18n,
profile_timeline.selected_view,
&profile_timeline.views,
);
let reversed = false; let reversed = false;
// poll for new notes and insert them into our existing notes // poll for new notes and insert them into our existing notes
@@ -183,7 +188,10 @@ impl<'a, 'd> ProfileView<'a, 'd> {
match profile_type { match profile_type {
ProfileType::MyProfile => { ProfileType::MyProfile => {
if ui.add(edit_profile_button()).clicked() { if ui
.add(edit_profile_button(self.note_context.i18n))
.clicked()
{
action = Some(ProfileViewAction::EditProfile); 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 { |ui: &mut egui::Ui| -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click()); let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
let painter = ui.painter_at(rect); 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 edit_icon_size = vec2(16.0, 16.0);
let galley = painter.layout( 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()), NotedeckTextStyle::Button.get_font_id(ui.ctx()),
ui.visuals().text_color(), ui.visuals().text_color(),
rect.width(), rect.width(),

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use crate::ui::{Preview, PreviewConfig}; use crate::ui::{Preview, PreviewConfig};
use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2}; use egui::{Align, Button, CornerRadius, Frame, Id, Layout, Margin, Rgba, RichText, Ui, Vec2};
use enostr::{RelayPool, RelayStatus}; use enostr::{RelayPool, RelayStatus};
use notedeck::{NotedeckTextStyle, RelayAction}; use notedeck::{tr, Localization, NotedeckTextStyle, RelayAction};
use notedeck_ui::app_images; use notedeck_ui::app_images;
use notedeck_ui::{colors::PINK, padding}; use notedeck_ui::{colors::PINK, padding};
use tracing::debug; use tracing::debug;
@@ -13,6 +13,7 @@ use super::widgets::styled_button;
pub struct RelayView<'a> { pub struct RelayView<'a> {
pool: &'a RelayPool, pool: &'a RelayPool,
id_string_map: &'a mut HashMap<Id, String>, id_string_map: &'a mut HashMap<Id, String>,
i18n: &'a mut Localization,
} }
impl RelayView<'_> { impl RelayView<'_> {
@@ -26,7 +27,7 @@ impl RelayView<'_> {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(Layout::left_to_right(Align::Center), |ui| { ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.label( ui.label(
RichText::new("Relays") RichText::new(tr!(self.i18n, "Relays", "Label for relay list section"))
.text_style(NotedeckTextStyle::Heading2.text_style()), .text_style(NotedeckTextStyle::Heading2.text_style()),
); );
}); });
@@ -53,10 +54,15 @@ impl RelayView<'_> {
} }
impl<'a> RelayView<'a> { 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 { RelayView {
pool, pool,
id_string_map, 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 /// 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; let mut relay_to_remove = None;
for (index, relay_info) in get_relay_infos(self.pool).iter().enumerate() { for (index, relay_info) in get_relay_infos(self.pool).iter().enumerate() {
ui.add_space(8.0); ui.add_space(8.0);
@@ -107,7 +113,7 @@ impl<'a> RelayView<'a> {
relay_to_remove = Some(relay_info.relay_url.to_string()); 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) { match self.id_string_map.get(&id) {
None => { None => {
ui.with_layout(Layout::top_down(Align::Min), |ui| { 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() { if ui.add(relay_button).clicked() {
debug!("add relay clicked"); debug!("add relay clicked");
self.id_string_map self.id_string_map
@@ -150,7 +156,11 @@ impl<'a> RelayView<'a> {
let is_enabled = self.pool.is_valid_url(text_buffer); let is_enabled = self.pool.is_valid_url(text_buffer);
let text_edit = egui::TextEdit::singleline(text_buffer) let text_edit = egui::TextEdit::singleline(text_buffer)
.hint_text( .hint_text(
RichText::new("Enter the relay here") RichText::new(tr!(
self.i18n,
"Enter the relay here",
"Placeholder for relay input field"
))
.text_style(NotedeckTextStyle::Body.text_style()), .text_style(NotedeckTextStyle::Body.text_style()),
) )
.vertical_align(Align::Center) .vertical_align(Align::Center)
@@ -160,7 +170,10 @@ impl<'a> RelayView<'a> {
ui.add(text_edit); ui.add(text_edit);
ui.add_space(8.0); ui.add_space(8.0);
if ui 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() .clicked()
{ {
self.id_string_map.remove(&id) // remove and return the value 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( Button::image_and_text(
app_images::add_relay_image().fit_to_exact_size(Vec2::new(48.0, 48.0)), 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) .size(16.0)
// TODO: this color should not be hard coded. Find some way to add it to the visuals // TODO: this color should not be hard coded. Find some way to add it to the visuals
.color(PINK), .color(PINK),
@@ -183,9 +196,10 @@ fn add_relay_button() -> Button<'static> {
.frame(false) .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 { 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) 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) .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 { let fg_color = match status {
RelayStatus::Connected => ui.visuals().selection.bg_fill, RelayStatus::Connected => ui.visuals().selection.bg_fill,
RelayStatus::Connecting => ui.visuals().warn_fg_color, 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 bg_color = egui::lerp(Rgba::from(fg_color)..=Rgba::BLACK, 0.8).into();
let label_text = match status { let label_text = match status {
RelayStatus::Connected => "Connected", RelayStatus::Connected => tr!(i18n, "Connected", "Status label for connected relay"),
RelayStatus::Connecting => "Connecting...", RelayStatus::Connecting => tr!(i18n, "Connecting...", "Status label for connecting relay"),
RelayStatus::Disconnected => "Not Connected", RelayStatus::Disconnected => {
tr!(i18n, "Not Connected", "Status label for disconnected relay")
}
}; };
let frame = Frame::new() let frame = Frame::new()
@@ -286,7 +302,7 @@ mod preview {
fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { fn update(&mut self, app: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
self.pool.try_recv(); self.pool.try_recv();
let mut id_string_map = HashMap::new(); 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 None
} }
} }

View File

@@ -5,7 +5,7 @@ use state::TypingType;
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
use egui_winit::clipboard::Clipboard; use egui_winit::clipboard::Clipboard;
use nostrdb::{Filter, Ndb, Transaction}; use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{NoteAction, NoteContext, NoteRef}; use notedeck::{tr, tr_plural, Localization, NoteAction, NoteContext, NoteRef};
use notedeck_ui::{ use notedeck_ui::{
context_menu::{input_context, PasteBehavior}, context_menu::{input_context, PasteBehavior},
icons::search_icon, icons::search_icon,
@@ -54,6 +54,7 @@ impl<'a, 'd> SearchView<'a, 'd> {
ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0); ui.spacing_mut().item_spacing = egui::vec2(0.0, 12.0);
let search_resp = search_box( let search_resp = search_box(
self.note_context.i18n,
&mut self.query.string, &mut self.query.string,
self.query.focus_state.clone(), self.query.focus_state.clone(),
ui, ui,
@@ -119,15 +120,23 @@ impl<'a, 'd> SearchView<'a, 'd> {
note_action = self.show_search_results(ui); note_action = self.show_search_results(ui);
} }
SearchState::Searched => { SearchState::Searched => {
ui.label(format!( ui.label(tr_plural!(
"Got {} results for '{}'", self.note_context.i18n,
self.query.notes.notes.len(), "Got {count} result for '{query}'", // one
&self.query.string "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); note_action = self.show_search_results(ui);
} }
SearchState::Typing(TypingType::AutoSearch) => { 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); note_action = self.show_search_results(ui);
} }
@@ -241,6 +250,7 @@ impl SearchResponse {
} }
fn search_box( fn search_box(
i18n: &mut Localization,
input: &mut String, input: &mut String,
focus_state: FocusState, focus_state: FocusState,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@@ -282,7 +292,14 @@ fn search_box(
let response = ui.add_sized( let response = ui.add_sized(
[ui.available_width(), search_height], [ui.available_width(), search_height],
TextEdit::singleline(input) 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) //.desired_width(available_width - 32.0)
//.font(egui::FontId::new(font_size, egui::FontFamily::Proportional)) //.font(egui::FontId::new(font_size, egui::FontFamily::Proportional))
.margin(vec2(0.0, 8.0)) .margin(vec2(0.0, 8.0))

View File

@@ -12,7 +12,7 @@ use crate::{
route::Route, route::Route,
}; };
use notedeck::{Accounts, UserAccount}; use notedeck::{tr, Accounts, Localization, UserAccount};
use notedeck_ui::{ use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
app_images, colors, View, app_images, colors, View,
@@ -26,6 +26,7 @@ static ICON_WIDTH: f32 = 40.0;
pub struct DesktopSidePanel<'a> { pub struct DesktopSidePanel<'a> {
selected_account: &'a UserAccount, selected_account: &'a UserAccount,
decks_cache: &'a DecksCache, decks_cache: &'a DecksCache,
i18n: &'a mut Localization,
} }
impl View for DesktopSidePanel<'_> { impl View for DesktopSidePanel<'_> {
@@ -58,10 +59,15 @@ impl SidePanelResponse {
} }
impl<'a> DesktopSidePanel<'a> { 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 { Self {
selected_account, selected_account,
decks_cache, decks_cache,
i18n,
} }
} }
@@ -105,7 +111,11 @@ impl<'a> DesktopSidePanel<'a> {
ui.add_space(8.0); ui.add_space(8.0);
ui.add(egui::Label::new( ui.add(egui::Label::new(
RichText::new("DECKS") RichText::new(tr!(
self.i18n,
"DECKS",
"Label for decks section in side panel"
))
.size(11.0) .size(11.0)
.color(ui.visuals().noninteractive().fg_stroke.color), .color(ui.visuals().noninteractive().fg_stroke.color),
)); ));
@@ -175,8 +185,9 @@ impl<'a> DesktopSidePanel<'a> {
decks_cache: &mut DecksCache, decks_cache: &mut DecksCache,
accounts: &Accounts, accounts: &Accounts,
action: SidePanelAction, action: SidePanelAction,
i18n: &mut Localization,
) -> Option<SwitchingAction> { ) -> 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; let mut switching_response = None;
match action { match action {
/* /*
@@ -218,7 +229,7 @@ impl<'a> DesktopSidePanel<'a> {
{ {
router.go_back(); router.go_back();
} else { } else {
get_active_columns_mut(accounts, decks_cache).new_column_picker(); get_active_columns_mut(i18n, accounts, decks_cache).new_column_picker();
} }
} }
SidePanelAction::ComposeNote => { SidePanelAction::ComposeNote => {
@@ -263,7 +274,7 @@ impl<'a> DesktopSidePanel<'a> {
switching_response = Some(crate::nav::SwitchingAction::Decks( switching_response = Some(crate::nav::SwitchingAction::Decks(
DecksAction::Switch(index), 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() .decks_mut()
.get_mut(index) .get_mut(index)
{ {

View File

@@ -1,5 +1,5 @@
use egui::{vec2, Button, Label, Layout, RichText}; use egui::{vec2, Button, Label, Layout, RichText};
use notedeck::{NamedFontFamily, NotedeckTextStyle}; use notedeck::{tr, Localization, NamedFontFamily, NotedeckTextStyle};
use notedeck_ui::{colors::PINK, padding}; use notedeck_ui::{colors::PINK, padding};
use tracing::error; use tracing::error;
@@ -7,11 +7,12 @@ use crate::support::Support;
pub struct SupportView<'a> { pub struct SupportView<'a> {
support: &'a mut Support, support: &'a mut Support,
i18n: &'a mut Localization,
} }
impl<'a> SupportView<'a> { impl<'a> SupportView<'a> {
pub fn new(support: &'a mut Support) -> Self { pub fn new(support: &'a mut Support, i18n: &'a mut Localization) -> Self {
Self { support } Self { support, i18n }
} }
pub fn show(&mut self, ui: &mut egui::Ui) { 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), notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
); );
ui.add(Label::new(RichText::new("Running into a bug?").font(font))); ui.add(Label::new(
ui.label(RichText::new("Step 1").text_style(NotedeckTextStyle::Heading3.text_style())); 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| { 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); let size = vec2(120.0, 40.0);
ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| {
let font_size = let font_size =
notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); 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 button_resp.clicked() {
if let Err(e) = open::that(self.support.get_mailto_url()) { if let Err(e) = open::that(self.support.get_mailto_url()) {
error!( error!(
@@ -47,16 +66,23 @@ impl<'a> SupportView<'a> {
if let Some(logs) = self.support.get_most_recent_log() { if let Some(logs) = self.support.get_most_recent_log() {
ui.label( 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 size = vec2(80.0, 40.0);
let copy_button = Button::new(RichText::new("Copy").size( 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), notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
)) ),
)
.fill(PINK) .fill(PINK)
.min_size(size); .min_size(size);
padding(8.0, ui, |ui| { 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| { ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| {
if ui.add(copy_button).clicked() { if ui.add(copy_button).clicked() {
ui.ctx().copy_text(logs.to_string()); 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 { fn open_email_button(
Button::new(RichText::new("Open Email").size(font_size)) 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) .fill(PINK)
.min_size(size) .min_size(size)
} }

View File

@@ -8,7 +8,9 @@ use std::f32::consts::PI;
use tracing::{error, warn}; use tracing::{error, warn};
use crate::timeline::{TimelineCache, TimelineKind, TimelineTab, ViewFilter}; 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::{ use notedeck_ui::{
anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE},
NoteOptions, NoteView, NoteOptions, NoteView,
@@ -103,7 +105,12 @@ fn timeline_ui(
return None; 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?? // need this for some reason??
ui.add_space(3.0); 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; ui.spacing_mut().item_spacing.y = 0.0;
let tab_res = egui_tabs::Tabs::new(views.len() as i32) 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 ind = state.index();
let txt = match views[ind as usize].filter { let txt = match views[ind as usize].filter {
ViewFilter::Notes => "Notes", ViewFilter::Notes => tr!(i18n, "Notes", "Label for notes-only filter"),
ViewFilter::NotesAndReplies => "Notes & Replies", 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 // underline
if state.is_selected() { if state.is_selected() {
let rect = res.rect; let rect = res.rect;
let underline = 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)] #[allow(deprecated)]
let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5; let underline_y = ui.painter().round_to_pixel(rect.bottom()) - 1.5;
return (underline, underline_y); return (underline, underline_y);

View File

@@ -1,7 +1,7 @@
use egui::{vec2, CornerRadius, Layout}; use egui::{vec2, CornerRadius, Layout};
use notedeck::{ use notedeck::{
get_current_wallet, Accounts, DefaultZapMsats, GlobalWallet, NotedeckTextStyle, get_current_wallet, tr, Accounts, DefaultZapMsats, GlobalWallet, Localization,
PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet, NotedeckTextStyle, PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet,
}; };
use crate::{nav::RouterAction, route::Route}; use crate::{nav::RouterAction, route::Route};
@@ -153,11 +153,12 @@ impl WalletAction {
pub struct WalletView<'a> { pub struct WalletView<'a> {
state: WalletState<'a>, state: WalletState<'a>,
i18n: &'a mut Localization,
} }
impl<'a> WalletView<'a> { impl<'a> WalletView<'a> {
pub fn new(state: WalletState<'a>) -> Self { pub fn new(state: WalletState<'a>, i18n: &'a mut Localization) -> Self {
Self { state } Self { state, i18n }
} }
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<WalletAction> { pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<WalletAction> {
@@ -173,11 +174,17 @@ impl<'a> WalletView<'a> {
wallet, wallet,
default_zap_state, default_zap_state,
can_create_local_wallet, 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 { WalletState::NoWallet {
state, state,
show_local_only, show_local_only,
} => show_no_wallet(ui, state, *show_local_only), } => show_no_wallet(ui, self.i18n, state, *show_local_only),
} }
} }
} }
@@ -196,13 +203,18 @@ fn try_create_wallet(state: &mut WalletUIState) -> Option<Wallet> {
fn show_no_wallet( fn show_no_wallet(
ui: &mut egui::Ui, ui: &mut egui::Ui,
i18n: &mut Localization,
state: &mut WalletUIState, state: &mut WalletUIState,
show_local_only: bool, show_local_only: bool,
) -> Option<WalletAction> { ) -> Option<WalletAction> {
ui.horizontal_wrapped(|ui| 's: { ui.horizontal_wrapped(|ui| 's: {
let text_edit = egui::TextEdit::singleline(&mut state.buf) let text_edit = egui::TextEdit::singleline(&mut state.buf)
.hint_text( .hint_text(
egui::RichText::new("Paste your NWC URI here...") egui::RichText::new(tr!(
i18n,
"Paste your NWC URI here...",
"Placeholder text for NWC URI input"
))
.text_style(notedeck::NotedeckTextStyle::Body.text_style()), .text_style(notedeck::NotedeckTextStyle::Body.text_style()),
) )
.vertical_align(egui::Align::Center) .vertical_align(egui::Align::Center)
@@ -218,8 +230,16 @@ fn show_no_wallet(
}; };
let error_str = match error_msg { let error_str = match error_msg {
WalletError::InvalidURI => "Invalid NWC URI", WalletError::InvalidURI => tr!(
WalletError::NoWallet => "Add a wallet to continue", 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); ui.colored_label(ui.visuals().warn_fg_color, error_str);
}); });
@@ -229,13 +249,20 @@ fn show_no_wallet(
if show_local_only { if show_local_only {
ui.checkbox( ui.checkbox(
&mut state.for_local_only, &mut state.for_local_only,
tr!(
i18n,
"Use this wallet for the current account only", "Use this wallet for the current account only",
"Checkbox label for using wallet only for current account"
),
); );
ui.add_space(8.0); ui.add_space(8.0);
} }
ui.with_layout(Layout::top_down(egui::Align::Center), |ui| { ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
ui.add(styled_button("Add Wallet", notedeck_ui::colors::PINK)) ui.add(styled_button(
tr!(i18n, "Add Wallet", "Button label to add a wallet").as_str(),
notedeck_ui::colors::PINK,
))
.clicked() .clicked()
.then_some(WalletAction::SaveURI) .then_some(WalletAction::SaveURI)
}) })
@@ -244,6 +271,7 @@ fn show_no_wallet(
fn show_with_wallet( fn show_with_wallet(
ui: &mut egui::Ui, ui: &mut egui::Ui,
i18n: &mut Localization,
wallet: &mut Wallet, wallet: &mut Wallet,
default_zap_state: &mut DefaultZapState, default_zap_state: &mut DefaultZapState,
can_create_local_wallet: bool, 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: { ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| 's: {
if ui 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() .clicked()
{ {
action = Some(WalletAction::Delete); action = Some(WalletAction::Delete);
@@ -280,7 +311,11 @@ fn show_with_wallet(
&& ui && ui
.checkbox( .checkbox(
&mut false, &mut false,
tr!(
i18n,
"Add a different wallet that will only be used for this account", "Add a different wallet that will only be used for this account",
"Button label to add a different wallet"
),
) )
.clicked() .clicked()
{ {
@@ -302,13 +337,17 @@ fn show_balance(ui: &mut egui::Ui, msats: u64) -> egui::Response {
.inner .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; let mut action = None;
ui.allocate_ui_with_layout( ui.allocate_ui_with_layout(
vec2(ui.available_width(), 50.0), vec2(ui.available_width(), 50.0),
egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true), egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
|ui| { |ui| {
ui.label("Default amount per zap: "); ui.label(tr!(i18n, "Default amount per zap: ", "Label for default zap amount input"));
match state { match state {
DefaultZapState::Pending(pending_default_zap_state) => { DefaultZapState::Pending(pending_default_zap_state) => {
let text = &mut pending_default_zap_state.amount_sats; 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.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 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() .clicked()
{ {
action = Some(WalletAction::SetDefaultZapSats(text.to_string())); action = Some(WalletAction::SetDefaultZapSats(text.to_string()));
} }
} }
DefaultZapState::Valid(msats) => { 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); 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 DefaultZapState::Pending(pending) = state {
if let Some(error_message) = &pending.error_message { if let Some(error_message) = &pending.error_message {
let msg_str = match 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); 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 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 galley = {
let painter = ui.painter(); let painter = ui.painter();
@@ -388,7 +431,11 @@ fn show_valid_msats(ui: &mut egui::Ui, msats: u64) -> Option<WalletAction> {
let resp = resp let resp = resp
.on_hover_cursor(egui::CursorIcon::PointingHand) .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); let painter = ui.painter_at(resp.rect);

View File

@@ -4,7 +4,7 @@ use crate::{
}; };
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
use nostrdb::{Ndb, Transaction}; 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}; use notedeck_ui::{app_images, icons::search_icon, jobs::JobsCache, NoteOptions, ProfilePic};
/// DaveUi holds all of the data it needs to render itself /// 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)) .inner_margin(egui::Margin::same(8))
.fill(ui.visuals().extreme_bg_color) .fill(ui.visuals().extreme_bg_color)
.corner_radius(12.0) .corner_radius(12.0)
.show(ui, |ui| self.inputbox(ui)) .show(ui, |ui| self.inputbox(app_ctx.i18n, ui))
.inner; .inner;
let note_action = egui::ScrollArea::vertical() let note_action = egui::ScrollArea::vertical()
@@ -134,11 +134,11 @@ impl<'a> DaveUi<'a> {
.or(DaveResponse { action }) .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 { if self.trial {
ui.add(egui::Label::new( ui.add(egui::Label::new(
egui::RichText::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(), .weak(),
)); ));
@@ -160,7 +160,7 @@ impl<'a> DaveUi<'a> {
for message in self.chat { for message in self.chat {
let r = match message { let r = match message {
Message::Error(err) => { Message::Error(err) => {
self.error_chat(err, ui); self.error_chat(ctx.i18n, err, ui);
None None
} }
Message::User(msg) => { Message::User(msg) => {
@@ -220,6 +220,7 @@ impl<'a> DaveUi<'a> {
unknown_ids: ctx.unknown_ids, unknown_ids: ctx.unknown_ids,
clipboard: ctx.clipboard, clipboard: ctx.clipboard,
current_account_has_wallet: false, current_account_has_wallet: false,
i18n: ctx.i18n,
}; };
let txn = Transaction::new(note_context.ndb).unwrap(); let txn = Transaction::new(note_context.ndb).unwrap();
@@ -303,12 +304,19 @@ impl<'a> DaveUi<'a> {
note_action 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.add_space(Self::chat_margin(ui.ctx()) as f32);
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Max), |ui| { ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
let mut dave_response = DaveResponse::none(); 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(); dave_response = DaveResponse::send();
} }
@@ -322,7 +330,14 @@ impl<'a> DaveUi<'a> {
}, },
Key::Enter, 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), .frame(false),
); );

View File

@@ -323,6 +323,7 @@ pub fn render_note_contents(
&supported_medias, &supported_medias,
carousel_id, carousel_id,
trusted_media, trusted_media,
note_context.i18n,
); );
ui.add_space(2.0); ui.add_space(2.0);
} }

View File

@@ -1,6 +1,6 @@
use egui::{Rect, Vec2}; use egui::{Rect, Vec2};
use nostrdb::NoteKey; use nostrdb::NoteKey;
use notedeck::{BroadcastContext, NoteContextSelection}; use notedeck::{tr, BroadcastContext, Localization, NoteContextSelection};
pub struct NoteContextButton { pub struct NoteContextButton {
put_at: Option<Rect>, put_at: Option<Rect>,
@@ -105,35 +105,80 @@ impl NoteContextButton {
#[profiling::function] #[profiling::function]
pub fn menu( pub fn menu(
ui: &mut egui::Ui, ui: &mut egui::Ui,
i18n: &mut Localization,
button_response: egui::Response, button_response: egui::Response,
) -> Option<NoteContextSelection> { ) -> Option<NoteContextSelection> {
let mut context_selection: Option<NoteContextSelection> = None; let mut context_selection: Option<NoteContextSelection> = None;
stationary_arbitrary_menu_button(ui, button_response, |ui| { stationary_arbitrary_menu_button(ui, button_response, |ui| {
ui.set_max_width(200.0); 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); context_selection = Some(NoteContextSelection::CopyText);
ui.close_menu(); 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); context_selection = Some(NoteContextSelection::CopyPubkey);
ui.close_menu(); 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); context_selection = Some(NoteContextSelection::CopyNoteId);
ui.close_menu(); 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); context_selection = Some(NoteContextSelection::CopyNoteJSON);
ui.close_menu(); 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( context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::Everywhere, BroadcastContext::Everywhere,
)); ));
ui.close_menu(); 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( context_selection = Some(NoteContextSelection::Broadcast(
BroadcastContext::LocalNetwork, BroadcastContext::LocalNetwork,
)); ));

View File

@@ -6,8 +6,8 @@ use egui::{
}; };
use notedeck::{ use notedeck::{
fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url, fonts::get_font_size, note::MediaAction, show_one_error_message, supported_mime_hosted_at_url,
GifState, GifStateMap, Images, JobPool, MediaCache, MediaCacheType, NotedeckTextStyle, tr, GifState, GifStateMap, Images, JobPool, Localization, MediaCache, MediaCacheType,
TexturedImage, TexturesCache, UrlMimes, NotedeckTextStyle, TexturedImage, TexturesCache, UrlMimes,
}; };
use crate::{ use crate::{
@@ -20,6 +20,7 @@ use crate::{
AnimationHelper, PulseAlpha, AnimationHelper, PulseAlpha,
}; };
#[allow(clippy::too_many_arguments)]
pub(crate) fn image_carousel( pub(crate) fn image_carousel(
ui: &mut egui::Ui, ui: &mut egui::Ui,
img_cache: &mut Images, img_cache: &mut Images,
@@ -28,6 +29,7 @@ pub(crate) fn image_carousel(
medias: &[RenderableMedia], medias: &[RenderableMedia],
carousel_id: egui::Id, carousel_id: egui::Id,
trusted_media: bool, trusted_media: bool,
i18n: &mut Localization,
) -> Option<MediaAction> { ) -> Option<MediaAction> {
// let's make sure everything is within our area // let's make sure everything is within our area
@@ -69,9 +71,14 @@ pub(crate) fn image_carousel(
blur_type.clone(), blur_type.clone(),
); );
if let Some(cur_action) = if let Some(cur_action) = render_media(
render_media(ui, &mut img_cache.gif_states, media_state, url, height) ui,
{ &mut img_cache.gif_states,
media_state,
url,
height,
i18n,
) {
// clicked the media, lets set the active index // clicked the media, lets set the active index
if let MediaUIAction::Clicked = cur_action { if let MediaUIAction::Clicked = cur_action {
set_show_popup(ui, popup_id(carousel_id), true); 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); 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 action
} }
@@ -163,6 +177,7 @@ fn show_full_screen_media(
index: usize, index: usize,
img_cache: &mut Images, img_cache: &mut Images,
carousel_id: egui::Id, carousel_id: egui::Id,
i18n: &mut Localization,
) { ) {
Window::new("image_popup") Window::new("image_popup")
.title_bar(false) .title_bar(false)
@@ -201,6 +216,7 @@ fn show_full_screen_media(
cur_state.gifs, cur_state.gifs,
image_url, image_url,
carousel_id, carousel_id,
i18n,
); );
}) })
}); });
@@ -363,6 +379,7 @@ fn select_next_media(
next as usize next as usize
} }
#[allow(clippy::too_many_arguments)]
fn render_full_screen_media( fn render_full_screen_media(
ui: &mut egui::Ui, ui: &mut egui::Ui,
num_urls: usize, num_urls: usize,
@@ -371,6 +388,7 @@ fn render_full_screen_media(
gifs: &mut HashMap<String, GifState>, gifs: &mut HashMap<String, GifState>,
image_url: &str, image_url: &str,
carousel_id: egui::Id, carousel_id: egui::Id,
i18n: &mut Localization,
) { ) {
const TOP_BAR_HEIGHT: f32 = 30.0; const TOP_BAR_HEIGHT: f32 = 30.0;
const BOTTOM_BAR_HEIGHT: f32 = 60.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| { 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.ctx().copy_text(url.to_owned());
ui.close_menu(); ui.close_menu();
} }
@@ -650,10 +675,11 @@ fn render_media(
render_state: MediaRenderState, render_state: MediaRenderState,
url: &str, url: &str,
height: f32, height: f32,
i18n: &mut Localization,
) -> Option<MediaUIAction> { ) -> Option<MediaUIAction> {
match render_state { match render_state {
MediaRenderState::ActualImage(image) => { 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) Some(MediaUIAction::Clicked)
} else { } else {
None None
@@ -692,9 +718,9 @@ fn render_media(
let resp = match obfuscated_texture { let resp = match obfuscated_texture {
ObfuscatedTexture::Blur(texture_handle) => { ObfuscatedTexture::Blur(texture_handle) => {
let resp = ui.add(texture_to_image(texture_handle, height)); 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 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 helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect);
let painter = ui.painter_at(helper.get_animation_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(), text_style.font_family(),
); );
let info_galley = painter.layout( 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(), animation_fontid.clone(),
ui.visuals().text_color(), ui.visuals().text_color(),
render_rect.width() / 2.0, render_rect.width() / 2.0,
); );
let load_galley = painter.layout_no_wrap( 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, animation_fontid,
egui::Color32::BLACK, egui::Color32::BLACK,
// ui.visuals().widgets.inactive.bg_fill, // 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() 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); 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 { 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, tex: &mut TexturedImage,
gifs: &mut GifStateMap, gifs: &mut GifStateMap,
height: f32, height: f32,
i18n: &mut Localization,
) -> Response { ) -> Response {
let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex)); let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex));
let img = texture_to_image(texture, height); let img = texture_to_image(texture, height);
let img_resp = ui.add(Button::image(img).frame(false)); let img_resp = ui.add(Button::image(img).frame(false));
copy_link(url, &img_resp); copy_link(i18n, url, &img_resp);
img_resp img_resp
} }

View File

@@ -17,6 +17,7 @@ use notedeck::note::MediaAction;
use notedeck::note::ZapTargetAmount; use notedeck::note::ZapTargetAmount;
use notedeck::ui::is_narrow; use notedeck::ui::is_narrow;
use notedeck::Images; use notedeck::Images;
use notedeck::Localization;
pub use options::NoteOptions; pub use options::NoteOptions;
pub use reply_description::reply_desc; pub use reply_description::reply_desc;
@@ -27,8 +28,8 @@ use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
use notedeck::{ use notedeck::{
name::get_display_name, name::get_display_name,
note::{NoteAction, NoteContext, ZapAction}, note::{NoteAction, NoteContext, ZapAction},
AnyZapState, CachedNote, ContextSelection, NoteCache, NoteZapTarget, NoteZapTargetOwned, tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle,
NotedeckTextStyle, ZapTarget, Zaps, ZapTarget, Zaps,
}; };
pub struct NoteView<'a, 'd> { 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 { 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"); let txn = self.note.txn().expect("todo: implement non-db notes");
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
@@ -206,21 +206,20 @@ impl<'a, 'd> NoteView<'a, 'd> {
//ui.horizontal(|ui| { //ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0; 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)); let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
ui.allocate_rect(rect, Sense::hover()); ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| { 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)); let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
ui.allocate_rect(rect, Sense::hover()); ui.allocate_rect(rect, Sense::hover());
ui.put(rect, |ui: &mut egui::Ui| { ui.put(rect, |ui: &mut egui::Ui| {
ui.add( ui.add(
Username::new(profile.as_ref().ok(), self.note.pubkey()) Username::new(
self.note_context.i18n,
profile.as_ref().ok(),
self.note.pubkey(),
)
.abbreviated(6) .abbreviated(6)
.pk_colored(true), .pk_colored(true),
) )
@@ -308,7 +307,11 @@ impl<'a, 'd> NoteView<'a, 'd> {
let color = ui.style().visuals.noninteractive().fg_stroke.color; let color = ui.style().visuals.noninteractive().fg_stroke.color;
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
RichText::new("Reposted") RichText::new(tr!(
self.note_context.i18n,
"Reposted",
"Label for reposted notes"
))
.color(color) .color(color)
.text_style(style.text_style()), .text_style(style.text_style()),
); );
@@ -348,20 +351,17 @@ impl<'a, 'd> NoteView<'a, 'd> {
#[profiling::function] #[profiling::function]
fn note_header( fn note_header(
ui: &mut egui::Ui, ui: &mut egui::Ui,
note_cache: &mut NoteCache, i18n: &mut Localization,
note: &Note, note: &Note,
profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
show_unread_indicator: bool, show_unread_indicator: bool,
) { ) {
let note_key = note.key().unwrap();
let horiz_resp = ui let horiz_resp = ui
.horizontal(|ui| { .horizontal(|ui| {
ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; 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, i18n, note.created_at(), true);
render_reltime(ui, cached_note, true);
}) })
.response; .response;
@@ -405,7 +405,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
ui.horizontal_centered(|ui| { ui.horizontal_centered(|ui| {
NoteView::note_header( NoteView::note_header(
ui, ui,
self.note_context.note_cache, self.note_context.i18n,
self.note, self.note,
profile, profile,
self.show_unread_indicator, self.show_unread_indicator,
@@ -460,8 +460,14 @@ impl<'a, 'd> NoteView<'a, 'd> {
cur_acc: cur_acc.keypair(), cur_acc: cur_acc.keypair(),
}) })
}; };
note_action = note_action = render_note_actionbar(
render_note_actionbar(ui, zapper, self.note.id(), self.note.pubkey(), note_key) ui,
zapper,
self.note.id(),
self.note.pubkey(),
note_key,
self.note_context.i18n,
)
.inner .inner
.or(note_action); .or(note_action);
} }
@@ -489,7 +495,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
NoteView::note_header( NoteView::note_header(
ui, ui,
self.note_context.note_cache, self.note_context.i18n,
self.note, self.note,
profile, profile,
self.show_unread_indicator, self.show_unread_indicator,
@@ -542,6 +548,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
self.note.id(), self.note.id(),
self.note.pubkey(), self.note.pubkey(),
note_key, note_key,
self.note_context.i18n,
) )
.inner .inner
.or(note_action); .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)); 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 })); note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
} }
} }
@@ -765,11 +773,13 @@ fn render_note_actionbar(
note_id: &[u8; 32], note_id: &[u8; 32],
note_pubkey: &[u8; 32], note_pubkey: &[u8; 32],
note_key: NoteKey, note_key: NoteKey,
i18n: &mut Localization,
) -> egui::InnerResponse<Option<NoteAction>> { ) -> egui::InnerResponse<Option<NoteAction>> {
ui.horizontal(|ui| 's: { 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 = 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); let to_noteid = |id: &[u8; 32]| NoteId::new(*id);
if reply_resp.clicked() { if reply_resp.clicked() {
@@ -804,7 +814,7 @@ fn render_note_actionbar(
cur_acc.secret_key.as_ref()?; cur_acc.secret_key.as_ref()?;
match zap_state { 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) => { Err(err) => {
let (rect, _) = let (rect, _) =
ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
@@ -832,7 +842,8 @@ fn render_note_actionbar(
#[profiling::function] #[profiling::function]
fn render_reltime( fn render_reltime(
ui: &mut egui::Ui, ui: &mut egui::Ui,
note_cache: &mut CachedNote, i18n: &mut Localization,
created_at: u64,
before: bool, before: bool,
) -> egui::InnerResponse<()> { ) -> egui::InnerResponse<()> {
ui.horizontal(|ui| { ui.horizontal(|ui| {
@@ -840,7 +851,7 @@ fn render_reltime(
secondary_label(ui, ""); secondary_label(ui, "");
} }
secondary_label(ui, note_cache.reltime_str_mut()); secondary_label(ui, notedeck::time_ago_since(i18n, created_at));
if !before { if !before {
secondary_label(ui, ""); 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 { let img = if ui.style().visuals.dark_mode {
app_images::reply_dark_image() app_images::reply_dark_image()
} else { } 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 expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
let put_resp = ui let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!(
.put(rect, img.max_width(size)) i18n,
.on_hover_text("Reply to this note"); "Reply to this note",
"Hover text for reply button"
));
resp.union(put_resp) 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 size = 14.0;
let expand_size = 5.0; let expand_size = 5.0;
let anim_speed = 0.05; 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 let put_resp = ui
.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)) .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) 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 { move |ui: &mut egui::Ui| -> egui::Response {
let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); 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 expand_size = 5.0; // from hover_expand_small
let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 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) resp.union(put_resp)
} }

View File

@@ -1,35 +1,143 @@
use egui::{Label, RichText, Sense}; use egui::{Label, RichText, Sense};
use nostrdb::{Note, NoteReply, Transaction}; use nostrdb::{NoteReply, Transaction};
use super::NoteOptions; use super::NoteOptions;
use crate::{jobs::JobsCache, note::NoteView, Mention}; use crate::{jobs::JobsCache, note::NoteView, Mention};
use notedeck::{NoteAction, NoteContext}; use notedeck::{tr, NoteAction, NoteContext};
#[must_use = "Please handle the resulting note action"] // Rich text segment types for internationalized rendering
#[profiling::function] #[derive(Debug, Clone)]
pub fn reply_desc( 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, ui: &mut egui::Ui,
segments: &[TextSegment],
txn: &Transaction, txn: &Transaction,
note_reply: &NoteReply,
note_context: &mut NoteContext, note_context: &mut NoteContext,
note_options: NoteOptions, note_options: NoteOptions,
jobs: &mut JobsCache, jobs: &mut JobsCache,
size: f32,
selectable: bool,
) -> Option<NoteAction> { ) -> Option<NoteAction> {
let mut note_action: Option<NoteAction> = None; let mut note_action: Option<NoteAction> = None;
let size = 10.0;
let selectable = false;
let visuals = ui.visuals(); let visuals = ui.visuals();
let color = visuals.noninteractive().fg_stroke.color; let color = visuals.noninteractive().fg_stroke.color;
let link_color = visuals.hyperlink_color; let link_color = visuals.hyperlink_color;
// note link renderer helper for segment in segments {
let note_link = |ui: &mut egui::Ui, match segment {
note_context: &mut NoteContext, TextSegment::Plain(text) => {
text: &str, ui.add(
note: &Note<'_>, Label::new(RichText::new(text).size(size).color(color)).selectable(selectable),
jobs: &mut JobsCache| { );
}
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 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( let r = ui.add(
Label::new(RichText::new(text).size(size).color(link_color)) Label::new(
RichText::new(tr!(
note_context.i18n,
"note",
"Link text for note references"
))
.size(size)
.color(link_color),
)
.sense(Sense::click()) .sense(Sense::click())
.selectable(selectable), .selectable(selectable),
); );
@@ -41,137 +149,170 @@ pub fn reply_desc(
if r.hovered() { if r.hovered() {
r.on_hover_ui_at_pointer(|ui| { r.on_hover_ui_at_pointer(|ui| {
ui.set_max_width(400.0); ui.set_max_width(400.0);
NoteView::new(note_context, note, note_options, jobs) NoteView::new(note_context, &note, note_options, jobs)
.actionbar(false) .actionbar(false)
.wide(true) .wide(true)
.show(ui); .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),
);
ui.add(Label::new(RichText::new("replying to").size(size).color(color)).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, &note, note_options, jobs)
.actionbar(false)
.wide(true)
.show(ui);
});
}
}
}
}
}
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_reply.reply()?;
let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) { let reply_note = if let Ok(reply_note) = note_context.ndb.get_note_by_id(txn, reply.id) {
reply_note reply_note
} else { } else {
ui.add(Label::new(RichText::new("a note").size(size).color(color)).selectable(selectable)); // Handle case where reply note is not found
return None; 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() { let segments = if note_reply.is_reply_to_root() {
// We're replying to the root, let's show this // Template: "replying to {user}'s {thread}"
let action = Mention::new( let template = tr!(
note_context.ndb, note_context.i18n,
note_context.img_cache, "replying to {user}'s {thread}",
txn, "Template for replying to root thread",
user = "{user}",
thread = "{thread}"
);
let segments = parse_i18n_template(&template);
fill_template_data(
segments,
reply_note.pubkey(), 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() { } 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 let Ok(root_note) = note_context.ndb.get_note_by_id(txn, root.id) {
if root_note.pubkey() == reply_note.pubkey() { if root_note.pubkey() == reply_note.pubkey() {
// simply "replying to bob's note" when replying to bob in his thread // Template: "replying to {user}'s {note}"
let action = Mention::new( let template = tr!(
note_context.ndb, note_context.i18n,
note_context.img_cache, "replying to {user}'s {note}",
txn, "Template for replying to user's note",
reply_note.pubkey(), user = "{user}",
) note = "{note}"
.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),
); );
let segments = parse_i18n_template(&template);
note_link(ui, note_context, "note", &reply_note, jobs); fill_template_data(segments, reply_note.pubkey(), reply.id, None, None)
} else { } else {
// replying to bob in alice's thread // Template: "replying to {reply_user}'s {note} in {thread_user}'s {thread}"
// This would need more sophisticated placeholder handling
let action = Mention::new( let template = tr!(
note_context.ndb, note_context.i18n,
note_context.img_cache, "replying to {user}'s {note} in {thread_user}'s {thread}",
txn, "Template for replying to note in different user's thread",
user = "{user}",
note = "{note}",
thread_user = "{thread_user}",
thread = "{thread}"
);
let segments = parse_i18n_template(&template);
fill_template_data(
segments,
reply_note.pubkey(), 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, "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(),
)
.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 { } else {
let action = Mention::new( // Template: "replying to {user} in someone's thread"
note_context.ndb, let template = tr!(
note_context.img_cache, note_context.i18n,
txn, "replying to {user} in someone's thread",
reply_note.pubkey(), "Template for replying to user in unknown thread",
) user = "{user}"
.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),
); );
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,
)
} }

View File

@@ -3,7 +3,9 @@ use egui::{Frame, Label, RichText};
use egui_extras::Size; use egui_extras::Size;
use nostrdb::ProfileRecord; 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}; use super::{about_section_widget, banner, display_name_widget};
@@ -68,6 +70,7 @@ impl egui::Widget for ProfilePreview<'_, '_> {
pub struct SimpleProfilePreview<'a, 'cache> { pub struct SimpleProfilePreview<'a, 'cache> {
profile: Option<&'a ProfileRecord<'a>>, profile: Option<&'a ProfileRecord<'a>>,
pub i18n: &'cache mut Localization,
cache: &'cache mut Images, cache: &'cache mut Images,
is_nsec: bool, is_nsec: bool,
} }
@@ -76,12 +79,14 @@ impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> {
pub fn new( pub fn new(
profile: Option<&'a ProfileRecord<'a>>, profile: Option<&'a ProfileRecord<'a>>,
cache: &'cache mut Images, cache: &'cache mut Images,
i18n: &'cache mut Localization,
is_nsec: bool, is_nsec: bool,
) -> Self { ) -> Self {
SimpleProfilePreview { SimpleProfilePreview {
profile, profile,
cache, cache,
is_nsec, is_nsec,
i18n,
} }
} }
} }
@@ -96,7 +101,11 @@ impl egui::Widget for SimpleProfilePreview<'_, '_> {
if !self.is_nsec { if !self.is_nsec {
ui.add( ui.add(
Label::new( Label::new(
RichText::new("Read only") RichText::new(tr!(
self.i18n,
"Read only",
"Label for read-only profile mode"
))
.size(notedeck::fonts::get_font_size( .size(notedeck::fonts::get_font_size(
ui.ctx(), ui.ctx(),
&NotedeckTextStyle::Tiny, &NotedeckTextStyle::Tiny,

View File

@@ -1,8 +1,9 @@
use egui::{Color32, RichText, Widget}; use egui::{Color32, RichText, Widget};
use nostrdb::ProfileRecord; use nostrdb::ProfileRecord;
use notedeck::fonts::NamedFontFamily; use notedeck::{fonts::NamedFontFamily, tr, Localization};
pub struct Username<'a> { pub struct Username<'a> {
i18n: &'a mut Localization,
profile: Option<&'a ProfileRecord<'a>>, profile: Option<&'a ProfileRecord<'a>>,
pk: &'a [u8; 32], pk: &'a [u8; 32],
pk_colored: bool, pk_colored: bool,
@@ -20,10 +21,15 @@ impl<'a> Username<'a> {
self 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 pk_colored = false;
let abbrev: usize = 1000; let abbrev: usize = 1000;
Username { Username {
i18n,
profile, profile,
pk, pk,
pk_colored, pk_colored,
@@ -52,7 +58,12 @@ impl Widget for Username<'_> {
} }
} }
} else { } 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 { if let Some(col) = color {
txt = txt.color(col) txt = txt.color(col)
} }

595
scripts/export_source_strings.py Executable file
View File

@@ -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()