Files
bitchat/bitchat/Services/LocationNotesManager.swift
T
0f7bcf17ff 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>
2025-09-29 22:31:59 +02:00

213 lines
7.9 KiB
Swift

import BitLogger
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
struct LocationNotesDependencies {
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
typealias Unsubscribe = @MainActor (_ id: String) -> Void
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
var relayLookup: RelayLookup
var subscribe: Subscribe
var unsubscribe: Unsubscribe
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
},
subscribe: { filter, id, relays, handler, onEOSE in
NostrRelayManager.shared.subscribe(
filter: filter,
id: id,
relayUrls: relays,
handler: handler,
onEOSE: onEOSE
)
},
unsubscribe: { id in
NostrRelayManager.shared.unsubscribe(id: id)
},
sendEvent: { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
}
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
/// Subscribes to and publishes notes for a given geohash and provides a send API.
@MainActor
final class LocationNotesManager: ObservableObject {
enum State: Equatable {
case idle
case loading
case ready
case noRelays
}
struct Note: Identifiable, Equatable {
let id: String
let pubkey: String
let content: String
let createdAt: Date
let nickname: String?
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
}
}
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
@Published private(set) var geohash: String
@Published private(set) var initialLoadComplete: Bool = false
@Published private(set) var state: State = .loading
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private let dependencies: LocationNotesDependencies
private enum Strings {
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
static func failedToSend(_ detail: String) -> String {
String(
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
locale: .current,
detail
)
}
}
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
self.geohash = geohash.lowercased()
self.dependencies = dependencies
subscribe()
}
func setGeohash(_ newGeohash: String) {
let norm = newGeohash.lowercased()
guard norm != geohash else { return }
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
geohash = norm
notes.removeAll()
subscribe()
}
func refresh() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
notes.removeAll()
subscribe()
}
func clearError() {
errorMessage = nil
}
private func subscribe() {
state = .loading
errorMessage = nil
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
subscriptionID = nil
initialLoadComplete = true
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
return
}
subscriptionID = subID
initialLoadComplete = false
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
dependencies.subscribe(filter, subID, relays, { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
// Ensure matching tag
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
if self.notes.contains(where: { $0.id == event.id }) { return }
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
self.notes.append(note)
self.notes.sort { $0.createdAt > $1.createdAt }
self.state = .ready
}, { [weak self] in
guard let self = self else { return }
self.initialLoadComplete = true
if self.state != .noRelays {
self.state = .ready
}
})
}
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
errorMessage = Strings.noRelays
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
return
}
do {
let id = try dependencies.deriveIdentity(geohash)
let event = try NostrProtocol.createGeohashTextNote(
content: trimmed,
geohash: geohash,
senderIdentity: id,
nickname: nickname
)
dependencies.sendEvent(event, relays)
// Optimistic local-echo
let echo = Note(
id: event.id,
pubkey: id.publicKeyHex,
content: trimmed,
createdAt: dependencies.now(),
nickname: nickname
)
self.notes.insert(echo, at: 0)
self.state = .ready
self.errorMessage = nil
} catch {
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
errorMessage = Strings.failedToSend(error.localizedDescription)
}
}
/// Explicitly cancel subscription and release resources.
func cancel() {
if let sub = subscriptionID {
dependencies.unsubscribe(sub)
subscriptionID = nil
}
state = .idle
errorMessage = nil
}
}