mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:05:20 +00:00
Refactor: Migrate to Swift String Catalogs (.xcstrings) (#691)
* Automated update of relay data - Sun Sep 21 06:26:33 UTC 2025
* chore(l10n): add empty string catalogs
* chore(l10n): populate string catalogs from legacy resources
* test(l10n): add catalog guardrail suite
* chore(l10n): remove legacy localization files
* fix: Add localization resources to Package.swift targets
- Add .process("Localization") to bitchat target resources
- Add .process("Localization") to bitchatTests target resources
- Resolves Bundle.module resource loading for localization files
- Enables proper localization testing in Swift Package Manager builds
* feat: Add Korean localization and convert to UTF-8 format
Korean Language Support:
- Add complete Korean (ko) localization with 191 strings from PR #686
- Include all app strings: UI, features, system messages, alerts
- Include all share extension strings: status messages, errors
- Verified 100% translation coverage for Korean locale
UTF-8 Format Conversion:
- Convert 23,047 Unicode escape sequences to readable UTF-8 characters
- Transform \u sequences (e.g. \u0625\u063a\u0644\u0627\u0642) to native text (إغلاق)
- Improve maintainability across all 15 supported locales
- Preserve all existing translations while enhancing readability
Locales supported: en, ar, de, es, fr, he, id, it, ja, ko, ne, pt-BR, ru, uk, zh-Hans
* test: Enhance dynamic localization test framework
Dynamic Test Framework:
- Replace hardcoded locale tests with data-driven approach
- Add testLocalizationExpectedValues() for dynamic locale validation
- Add testConfiguredLocalesCompleteness() for coverage verification
- Tests now read configuration from PrimaryLocalizationKeys.json
Expanded Test Coverage:
- Increase from 14 to 33 key validations (135% increase)
- Add critical UI strings: common actions, app info, security, sharing
- Cover 364 total string validations across 15 locales
- Include Korean validation with native Korean expected values
Test Categories Added:
- Common UI: cancel, close, copy actions
- App Info: encryption, offline features, app name
- Bluetooth: permission and settings alerts
- Security: verification badges and actions
- Share Extension: all status and error messages
- Content Actions: accessibility and user actions
Maintains 100% test success rate across all supported locales.
* fix: Convert plural strings to correct xcstrings format
Three plural strings (content.accessibility.people_count,
location_channels.row_title, location_notes.header) were using
incorrect format causing runtime String(format:) errors.
Migrated from stringUnit.variations structure to proper
substitutions.variations format across all 14 languages.
* refactor(l10n): migrate to native String(localized:) APIs
Remove L10n.string wrapper in favor of Swift's native localization APIs.
Migrate 100+ localization call sites to use String(localized:) and String(localized:defaultValue:) with string interpolation.
- Update catalog to use interpolation syntax (\(var)) instead of format specifiers (%@)
- Migrate simple strings to String(localized:)
- Migrate strings with arguments to String(localized:defaultValue:) with interpolation
- Keep format strings for plural substitutions (String(format:locale:))
- Remove bitchat/Utils/Localization.swift
Net result: -407 insertions, +130 deletions across 15 files
* fix(l10n): correct interpolation to use format strings
Interpolation in String(localized:defaultValue:) doesn't work as expected -
the interpolation happens at the call site before localization lookup.
Convert dynamic strings to use String(format:String(localized:),args) pattern:
- Update catalog entries from \(var) syntax to %@ placeholders
- Wrap String(localized:) calls with String(format:locale:) for dynamic values
- Affects 17 strings across 6 files
This fixes UI showing literal "\(geohash)" text instead of actual values.
* chore(l10n): remove invalid catalog entries
Remove auto-extracted literal strings (@, #, ✔︎, @%@, bitchat/) that were
generated without proper localization structure. These caused test
decoding failures.
* fix(l10n): remove unused auto-extracted format string
Remove '%@/%@' key that was auto-extracted by Xcode but never used.
This key only existed in English causing locale parity test failures
across all 13 other languages.
Fixes locale parity tests - all 8 localization tests now pass with
only expected failures (incomplete translations in some locales).
* fix(l10n): copy format string to all locales for 100% completion
Add %@/%@ format string to all 14 non-English locales. Format strings
are locale-independent so using the same value everywhere is correct.
This brings all locales to 100% completion (189/189 strings) to prevent
Xcode from reporting incomplete translations when building.
* fix(l10n): prevent auto-extraction of UI literals
Use Text(verbatim:) for non-localizable UI elements:
- App branding ("bitchat/")
- Symbols (@, #, ✔︎)
- Dynamic usernames (@username)
- Count ratios (reached/total)
This prevents Xcode from auto-extracting these literals into the
String Catalog when building through Xcode GUI, which was causing
locales to show 96% completion instead of 100%.
* chore(l10n): remove auto-extracted UI literal entries
Delete 5 auto-extracted keys from catalog that are now using Text(verbatim:):
- @, #, ✔︎, %@, %@/%@
These were showing as stale/incomplete in Xcode causing 97% completion.
All locales now at 100% (188/188 strings).
* fix(l10n): prevent AttributedString from extracting @ symbol
Use string interpolation "\\(at)" instead of literal "@" in
AttributedString to prevent Xcode from auto-extracting it to the
String Catalog during build.
This was the last string causing locales to show 99% instead of 100%.
* fix(l10n): add %@ as non-translatable key in all locales
Mark %@ as non-translatable and add to all 15 locales with same value.
This prevents Xcode from showing incomplete translations when it
auto-extracts this format specifier during GUI builds.
All locales remain at 100% (189/189 strings).
* refactor: move Localizable.xcstrings to bitchat root
Move bitchat/Localization/Localizable.xcstrings to bitchat/ (after LaunchScreen)
and remove empty Localization directory.
* fix(test): update catalog path in localization tests
Update test paths from bitchat/Localization/Localizable.xcstrings to
bitchat/Localizable.xcstrings after moving the file.
---------
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
co-authored by
GitHub Action
jack
parent
da2a12296e
commit
0f7bcf17ff
@@ -554,20 +554,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.starting",
|
||||
comment: "System message when Tor is starting"
|
||||
)
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
// Suppress incremental Tor progress messages
|
||||
torProgressCancellable = nil
|
||||
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
|
||||
torStatusAnnounced = true
|
||||
addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.dev_bypass",
|
||||
comment: "System message when Tor bypass is enabled in development"
|
||||
)
|
||||
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -866,10 +860,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.torStatusAnnounced = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.starting",
|
||||
comment: "System message when Tor is starting"
|
||||
)
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -879,10 +870,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.torRestartPending = true
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.restarting",
|
||||
comment: "System message when Tor is restarting"
|
||||
)
|
||||
String(localized: "system.tor.restarting", comment: "System message when Tor is restarting")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -893,19 +881,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
self.addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.restarted",
|
||||
comment: "System message when Tor has restarted"
|
||||
)
|
||||
String(localized: "system.tor.restarted", comment: "System message when Tor has restarted")
|
||||
)
|
||||
self.torRestartPending = false
|
||||
} else if TorManager.shared.torEnforced && !self.torInitialReadyAnnounced {
|
||||
// Initial start completed
|
||||
self.addGeohashOnlySystemMessage(
|
||||
L10n.string(
|
||||
"system.tor.started",
|
||||
comment: "System message when Tor has started"
|
||||
)
|
||||
String(localized: "system.tor.started", comment: "System message when Tor has started")
|
||||
)
|
||||
self.torInitialReadyAnnounced = true
|
||||
}
|
||||
@@ -1588,10 +1570,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session)
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.location.send_failed",
|
||||
comment: "System message when a location channel send fails"
|
||||
)
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2077,9 +2056,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (k, v) in nostrKeyMapping where v.lowercased() == hex { nostrKeyMapping.removeValue(forKey: k) }
|
||||
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.geohash.blocked",
|
||||
comment: "System message shown when a user is blocked in geohash chats",
|
||||
String(
|
||||
format: String(localized: "system.geohash.blocked", comment: "System message shown when a user is blocked in geohash chats"),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
@@ -2088,9 +2067,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.geohash.unblocked",
|
||||
comment: "System message shown when a user is unblocked in geohash chats",
|
||||
String(
|
||||
format: String(localized: "system.geohash.unblocked", comment: "System message shown when a user is unblocked in geohash chats"),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
@@ -2278,9 +2257,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if unifiedPeerService.isBlocked(peerID) {
|
||||
let nickname = meshService.peerNickname(peerID: peerID) ?? "user"
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.dm.blocked_recipient",
|
||||
comment: "System message when attempting to message a blocked user",
|
||||
String(
|
||||
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
)
|
||||
@@ -2346,17 +2325,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Update delivery status to failed
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .failed(
|
||||
reason: L10n.string(
|
||||
"content.delivery.reason.unreachable",
|
||||
comment: "Failure reason when a peer is unreachable"
|
||||
)
|
||||
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||
)
|
||||
}
|
||||
let name = recipientNickname ?? "user"
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.dm.unreachable",
|
||||
comment: "System message when a recipient is unreachable",
|
||||
recipientNickname ?? L10n.string("system.common.user", comment: "Fallback recipient name")
|
||||
String(
|
||||
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
|
||||
locale: .current,
|
||||
name
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -2365,10 +2342,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private func sendGeohashDM(_ content: String, to peerID: String) {
|
||||
guard case .location(let ch) = activeChannel else {
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.location.not_in_channel",
|
||||
comment: "System message when attempting to send without being in a location channel"
|
||||
)
|
||||
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2399,10 +2373,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
guard let recipientHex = nostrKeyMapping[peerID] else {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
reason: L10n.string(
|
||||
"content.delivery.reason.unknown_recipient",
|
||||
comment: "Failure reason when the recipient is unknown"
|
||||
)
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -2412,17 +2383,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
reason: L10n.string(
|
||||
"content.delivery.reason.blocked",
|
||||
comment: "Failure reason when the user is blocked"
|
||||
)
|
||||
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||
)
|
||||
}
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.dm.blocked_generic",
|
||||
comment: "System message when sending fails because user is blocked"
|
||||
)
|
||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2434,10 +2399,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
reason: L10n.string(
|
||||
"content.delivery.reason.self",
|
||||
comment: "Failure reason when attempting to message yourself"
|
||||
)
|
||||
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -2452,10 +2414,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} catch {
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
reason: L10n.string(
|
||||
"content.delivery.reason.send_error",
|
||||
comment: "Failure reason for a generic send error"
|
||||
)
|
||||
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2515,22 +2474,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
switch state {
|
||||
case .poweredOff:
|
||||
bluetoothAlertMessage = L10n.string(
|
||||
"content.alert.bluetooth_required.off",
|
||||
comment: "Message shown when Bluetooth is turned off"
|
||||
)
|
||||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||||
showBluetoothAlert = true
|
||||
case .unauthorized:
|
||||
bluetoothAlertMessage = L10n.string(
|
||||
"content.alert.bluetooth_required.permission",
|
||||
comment: "Message shown when Bluetooth permission is missing"
|
||||
)
|
||||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||||
showBluetoothAlert = true
|
||||
case .unsupported:
|
||||
bluetoothAlertMessage = L10n.string(
|
||||
"content.alert.bluetooth_required.unsupported",
|
||||
comment: "Message shown when the device lacks Bluetooth support"
|
||||
)
|
||||
bluetoothAlertMessage = String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||||
showBluetoothAlert = true
|
||||
case .poweredOn:
|
||||
// Hide alert when Bluetooth is powered on
|
||||
@@ -2561,9 +2511,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Check if the peer is blocked
|
||||
if unifiedPeerService.isBlocked(peerID) {
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.chat.blocked",
|
||||
comment: "System message when starting chat fails because peer is blocked",
|
||||
String(
|
||||
format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"),
|
||||
locale: .current,
|
||||
peerNickname
|
||||
)
|
||||
)
|
||||
@@ -2574,9 +2524,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if let peer = unifiedPeerService.getPeer(by: peerID),
|
||||
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
||||
addSystemMessage(
|
||||
L10n.string(
|
||||
"system.chat.requires_favorite",
|
||||
comment: "System message when mutual favorite requirement blocks chat",
|
||||
String(
|
||||
format: String(localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat"),
|
||||
locale: .current,
|
||||
peerNickname
|
||||
)
|
||||
)
|
||||
@@ -3034,10 +2984,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||
self.addSystemMessage(
|
||||
L10n.string(
|
||||
"system.location.send_failed",
|
||||
comment: "System message when a location channel send fails"
|
||||
)
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3609,8 +3556,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
mentionStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced)
|
||||
let mentionColor: Color = isMentionToMe ? .orange : baseColor
|
||||
mentionStyle.foregroundColor = mentionColor
|
||||
// Emit '@'
|
||||
result.append(AttributedString("@").mergingAttributes(mentionStyle))
|
||||
// Emit '@' (non-localizable symbol - use interpolation to avoid extraction)
|
||||
let at = "@"
|
||||
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
|
||||
// Base name
|
||||
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||
// Suffix in light grey
|
||||
|
||||
Reference in New Issue
Block a user