Localize the last hardcoded UI strings; wire up two dead translations (#1656)

* Add 26 code-referenced keys to the catalog + a test that closes the gap

LocalizationCoverageTests validated the catalog but never the code: a
String(localized:) whose key is missing from every catalog compiles
fine and silently ships its English defaultValue to all 29 non-source
locales. That blind spot let 26 keys go untranslated while CI stayed
green: the entire notices/board composer (10), the private-media
encryption warnings and delivery-failure reasons (9), both
courier-header strings, two delivery states, two media failure
reasons, and the [? people] channel row.

- All 26 keys added with full 30-locale coverage.
- New everyCodeReferencedKeyExistsInACatalog scans the source for
  literal String(localized:) keys and fails on any key absent from
  both catalogs. (The audit's original count of 17 was itself an
  undercount — its scan missed multi-line String( localized: calls;
  the test's regex does not.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Localize CommandProcessor: all 48 command strings, 30 locales

CommandProcessor had zero String(localized:) calls — every command
error, usage hint, and the entire /help reference was hardcoded
English. The autocomplete panel (CommandInfo) is fully localized, so a
non-English speaker got localized suggestions while typing and English
the moment anything went wrong or they asked for help.

- 47 literal sites converted to String(localized:)/String(format:)
  with positional specifiers where languages reorder (%1$@, %2$lld).
- /trace's "hop"/"hops" pluralization split into path_one/path_many;
  the "you" chain label localized too.
- helpText and groupUsage become computed so they resolve per-locale.
- 49 command.* keys added with full 30-locale coverage (command
  syntax stays verbatim; only prose translates).
- Copy fix from the audit: "blocked X. you will no longer receive
  messages from them" → "…no longer see their messages" (blocking
  filters at display time; packets still arrive and relay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Localize the last hardcoded UI strings; wire up two dead translations

The stragglers from the unlocalized-strings audit:

- Nearby push notification: "bitchatters nearby!" title and the
  hand-rolled English pluralization ("1 person around" / "N people
  around") now localize via body_one/body_many.
- Voice recording errors (mic permission, start/too-short/save
  failures) and the "Recording Error" alert title — also brought into
  the lowercase house voice.
- The recording HUD's Cancel label now uses common.cancel.
- "No image selected" in both image pickers.
- The DM screenshot echo "you took a screenshot" (the wire-format
  sibling stays English for Android compat).
- "Voice/Images are only available in mesh chats." had fully
  translated catalog entries in all 30 locales that were never looked
  up — the call sites passed the raw string instead of resolving the
  key. Wired up via String(localized:).

10 new keys x 30 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack 2026-08-10 20:40:14 +02:00 committed by GitHub
parent 5c4b814c56
commit 9b84b36122
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1878 additions and 16 deletions

File diff suppressed because it is too large Load Diff

View File

@ -260,8 +260,10 @@ final class NotificationService {
}
func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let title = String(localized: "notification.nearby.title", defaultValue: "👥 bitchatters nearby!", comment: "Title of the local notification when mesh peers come into range")
let body = peerCount == 1
? String(localized: "notification.nearby.body_one", defaultValue: "1 person around", comment: "Body of the nearby notification for exactly one peer")
: String(format: String(localized: "notification.nearby.body_many", defaultValue: "%lld people around", comment: "Body of the nearby notification; placeholder is the peer count"), locale: .current, peerCount)
// Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"

View File

@ -304,7 +304,7 @@ private extension ChatLifecycleCoordinator {
func appendPrivateScreenshotNotice(for peerID: PeerID) {
let notice = BitchatMessage(
sender: "system",
content: "you took a screenshot",
content: String(localized: "system.screenshot.you", defaultValue: "you took a screenshot", comment: "Local system line after your screenshot notice was sent to the DM peer"),
timestamp: Date(),
isRelay: false,
originalSender: nil,

View File

@ -551,7 +551,7 @@ final class ChatMediaTransferCoordinator {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
context.addSystemMessage("Voice notes are only available in mesh chats.")
context.addSystemMessage(String(localized: "Voice notes are only available in mesh chats.", comment: "System message when a voice note is attempted outside mesh chats"))
return
}
@ -694,7 +694,7 @@ final class ChatMediaTransferCoordinator {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
context.addSystemMessage("Images are only available in mesh chats.")
context.addSystemMessage(String(localized: "Images are only available in mesh chats.", comment: "System message when an image send is attempted outside mesh chats"))
return
}

View File

@ -29,7 +29,7 @@ final class VoiceRecordingViewModel: ObservableObject {
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
case .permissionDenied: String(localized: "voice.error.mic_permission", defaultValue: "microphone access is required to record voice notes.", comment: "Alert message when the microphone permission is denied")
case .idle, .requestingPermission, .preparing, .recording: ""
}
}
@ -156,7 +156,7 @@ final class VoiceRecordingViewModel: ObservableObject {
}
}
activeSession = nil
state = .error(message: "Could not start recording.")
state = .error(message: String(localized: "voice.error.start_failed", defaultValue: "could not start recording.", comment: "Alert message when the recorder fails to start"))
}
}
}
@ -202,8 +202,8 @@ final class VoiceRecordingViewModel: ObservableObject {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save")
: String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved")
)
return
}
@ -212,8 +212,8 @@ final class VoiceRecordingViewModel: ObservableObject {
guard generation == holdGeneration, state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save")
: String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved")
)
}
}

View File

@ -238,7 +238,7 @@ private extension ContentComposerView {
}
Spacer()
Button(action: voiceRecordingVM.cancel) {
Label("Cancel", systemImage: "xmark.circle")
Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle")
.labelStyle(.iconOnly)
.font(.bitchatSystem(size: 18))
.foregroundColor(.red)

View File

@ -282,7 +282,7 @@ struct ContentPeopleSheetView: View {
}
}
#endif
.alert("Recording Error", isPresented: voiceAlertBinding, actions: {
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: voiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {

View File

@ -397,7 +397,7 @@ struct ContentView: View {
ImagePreviewView(url: url)
}
}
.alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: {
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: rootVoiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {

View File

@ -64,7 +64,7 @@ struct ImagePickerView: UIViewControllerRepresentable {
.resizable()
.scaledToFit()
} else {
Text("No image selected")
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
}
Button("Show") { isPresented = true }
}

View File

@ -63,7 +63,7 @@ struct MacImagePickerView: View {
.resizable()
.scaledToFit()
} else {
Text("No image selected")
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
}
Button("Show") { isPresented = true }
}