diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 049ae56b..4b1a8448 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -279,8 +279,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { } } -extension String { - var nilIfEmpty: String? { - self.isEmpty ? nil : self - } -} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 515338ee..4b48f1e8 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -59,22 +59,11 @@ final class CommandProcessor { weak var meshService: Transport? private let identityManager: SecureIdentityStateManagerProtocol - /// Backward-compatible property for existing code - weak var chatViewModel: CommandContextProvider? { - get { contextProvider } - set { contextProvider = newValue } - } - init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { self.contextProvider = contextProvider self.meshService = meshService self.identityManager = identityManager } - - /// Backward-compatible initializer - convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { - self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager) - } /// Process a command string @MainActor diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 187e9b53..c231f22c 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -34,9 +34,20 @@ final class MessageRouter { } } + // MARK: - Transport Selection + + private func reachableTransport(for peerID: PeerID) -> Transport? { + transports.first { $0.isPeerReachable(peerID) } + } + + private func connectedTransport(for peerID: PeerID) -> Transport? { + transports.first { $0.isPeerConnected(peerID) } + } + + // MARK: - Message Sending + func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - // Try to find a reachable transport - if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) } else { @@ -48,38 +59,26 @@ final class MessageRouter { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) transport.sendReadReceipt(receipt, to: peerID) } else if !transports.isEmpty { - // Fallback to last transport (usually Nostr) if neither is explicitly reachable? - // Or better: just try the first one that supports it? - // Existing logic preferred mesh, then nostr. - // If neither reachable, existing logic queued it (via mesh usually) or sent via nostr. - // Let's stick to "try reachable". If none, maybe pick the first one to queue? - // Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport. - // But let's stick to the reachable check. SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) } } func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { - if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendDeliveryAck(for: messageID, to: peerID) } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) { + if let transport = connectedTransport(for: peerID) { + transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + } else if let transport = reachableTransport(for: peerID) { transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) - } else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { - transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) - } else { - // Fallback: try all? or just the last one? - // Old logic: if mesh connected, mesh. Else nostr. - // Note: NostrTransport.isPeerReachable now returns true if mapped. - // If not mapped, we can't send via Nostr anyway. } } @@ -89,16 +88,16 @@ final class MessageRouter { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) var remaining: [(content: String, nickname: String, messageID: String)] = [] - + for (content, nickname, messageID) in queued { - if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) } else { remaining.append((content, nickname, messageID)) } } - + if remaining.isEmpty { outbox.removeValue(forKey: peerID) } else { diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 3e2dccdc..860d2afd 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -118,32 +118,15 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } - guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - // Convert recipient npub -> hex (x-only) - let recipientHex: String - do { - let (hrp, data) = try Bech32.decode(recipientNpub) - guard hrp == "npub" else { - SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session) - return - } - recipientHex = data.hexEncodedString() - } catch { - SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session) - return - } + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) return } - guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { - SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session) - return - } - SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…", category: .session) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) } } @@ -158,58 +141,31 @@ final class NostrTransport: Transport, @unchecked Sendable { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } - guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session) - // Convert recipient npub -> hex - let recipientHex: String - do { - let (hrp, data) = try Bech32.decode(recipientNpub) - guard hrp == "npub" else { return } - recipientHex = data.hexEncodedString() - } catch { - SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session) - return - } guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session) return } - guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { - SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session) - return - } - SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) } } func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendDeliveryAck(for messageID: String, to peerID: PeerID) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return } - guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } - SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session) - let recipientHex: String - do { - let (hrp, data) = try Bech32.decode(recipientNpub) - guard hrp == "npub" else { return } - recipientHex = data.hexEncodedString() - } catch { - SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session) - return - } + guard let recipientNpub = resolveRecipientNpub(for: peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) return } - guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { - SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session) - return - } - SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) } } } @@ -221,21 +177,17 @@ extension NostrTransport { // MARK: Geohash ACK helpers func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { Task { @MainActor in - SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session) + SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } - guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } - NostrRelayManager.registerPendingGiftWrap(id: event.id) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { Task { @MainActor in - SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session) + SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } - guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } - NostrRelayManager.registerPendingGiftWrap(id: event.id) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } @@ -243,19 +195,12 @@ extension NostrTransport { func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { Task { @MainActor in guard !recipientHex.isEmpty else { return } - SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session) - // Build embedded BitChat packet without recipient peer ID + SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))…", category: .session) guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) return } - guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { - SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session) - return - } - SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session) - NostrRelayManager.registerPendingGiftWrap(id: event.id) - NostrRelayManager.shared.sendEvent(event) + sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) } } } @@ -263,6 +208,32 @@ extension NostrTransport { // MARK: - Private Helpers extension NostrTransport { + /// Converts npub bech32 string to hex pubkey + @MainActor + private func npubToHex(_ npub: String) -> String? { + do { + let (hrp, data) = try Bech32.decode(npub) + guard hrp == "npub" else { return nil } + return data.hexEncodedString() + } catch { + SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session) + return nil + } + } + + /// Creates and sends a gift-wrapped private message event + @MainActor + private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) { + guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { + SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session) + return + } + if registerPending { + NostrRelayManager.registerPendingGiftWrap(id: event.id) + } + NostrRelayManager.shared.sendEvent(event) + } + /// Must be called within a barrier on `queue` private func processReadQueueIfNeeded() { guard !isSendingReadAcks else { return } @@ -275,31 +246,16 @@ extension NostrTransport { /// Sends a single read ack item (called after extraction from queue within barrier) private func sendReadAckItem(_ item: QueuedRead) { Task { @MainActor in - guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return } - guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } - SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session) - // Convert recipient npub -> hex - let recipientHex: String - do { - let (hrp, data) = try Bech32.decode(recipientNpub) - guard hrp == "npub" else { scheduleNextReadAck(); return } - recipientHex = data.hexEncodedString() - } catch { - SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session) - scheduleNextReadAck() - return - } + defer { scheduleNextReadAck() } + guard let recipientNpub = resolveRecipientNpub(for: item.peerID), + let recipientHex = npubToHex(recipientNpub), + let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } + SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) - scheduleNextReadAck(); return + return } - guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { - SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session) - scheduleNextReadAck(); return - } - SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…", category: .session) - NostrRelayManager.shared.sendEvent(event) - scheduleNextReadAck() + sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 850082b5..cac0c298 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -448,7 +448,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv self.deduplicationService = MessageDeduplicationService() // Wire up dependencies - self.commandProcessor.chatViewModel = self + self.commandProcessor.contextProvider = self self.participantTracker.configure(context: self) // Subscribe to privateChatManager changes to trigger UI updates diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 343fecef..c9db45e5 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -6,7 +6,7 @@ struct CommandProcessorTests { @MainActor @Test func slapNotFoundGrammar() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) + let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap @system") switch result { case .error(let message): @@ -18,7 +18,7 @@ struct CommandProcessorTests { @MainActor @Test func hugNotFoundGrammar() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) + let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/hug @system") switch result { case .error(let message): @@ -30,7 +30,7 @@ struct CommandProcessorTests { @MainActor @Test func slapUsageMessage() { - let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) + let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let result = processor.process("/slap") switch result { case .error(let message): diff --git a/bitchatTests/LocalizationCatalogTests.swift b/bitchatTests/LocalizationCatalogTests.swift deleted file mode 100644 index 81716271..00000000 --- a/bitchatTests/LocalizationCatalogTests.swift +++ /dev/null @@ -1,530 +0,0 @@ -//import Foundation -//import XCTest -//@testable import bitchat -// -//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() -//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent() -//private let repoRootURL = testsRootURL.deletingLastPathComponent() -// -//final class LocalizationCatalogTests: XCTestCase { -// // Ensures every app locale includes exactly the same keys as Base. -// func testAppCatalogLocaleParity() throws { -// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings") -// assertLocaleParity(context: context, catalogName: "App") -// } -// -// // Verifies format placeholders stay consistent across app locales. -// func testAppCatalogPlaceholderConsistency() throws { -// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings") -// assertPlaceholderConsistency(context: context, catalogName: "App") -// } -// -// // Guards a core set of app strings from going empty per locale. -// func testAppPrimaryKeysNonEmpty() throws { -// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings") -// let primaryKeys = try loadPrimaryKeys().app -// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App") -// } -// -// // Ensures every share extension locale matches Base key coverage. -// func testShareExtensionCatalogLocaleParity() throws { -// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings") -// assertLocaleParity(context: context, catalogName: "ShareExtension") -// } -// -// // Verifies share extension placeholders align across locales. -// func testShareExtensionCatalogPlaceholderConsistency() throws { -// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings") -// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension") -// } -// -// // Confirms critical share extension strings remain non-empty per locale. -// func testShareExtensionPrimaryKeysNonEmpty() throws { -// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings") -// let primaryKeys = try loadPrimaryKeys().shareExtension -// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension") -// } -// -// // Validates that configured locales contain expected string values. -// func testLocalizationExpectedValues() throws { -// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings") -// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings") -// let config = try loadPrimaryKeys() -// -// guard let testLocales = config.testLocales else { -// // If no testLocales specified, skip this test -// return -// } -// -// guard let expectedValues = config.expectedValues else { -// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json") -// return -// } -// -// // Loop through each locale to test -// for locale in testLocales { -// guard let localeExpectedValues = expectedValues[locale] else { -// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json") -// continue -// } -// -// // Test each expected key/value pair for this locale -// for (key, expectedValue) in localeExpectedValues { -// if config.app.contains(key) { -// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App") -// } else if config.shareExtension.contains(key) { -// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension") -// } -// } -// } -// } -// -// // Ensures configured test locales are present and complete. -// func testConfiguredLocalesCompleteness() throws { -// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings") -// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings") -// let config = try loadPrimaryKeys() -// -// guard let testLocales = config.testLocales else { -// // If no testLocales specified, skip this test -// return -// } -// -// let baseLocale = appContext.baseLocale -// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set() -// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set() -// -// for locale in testLocales { -// // Skip base locale comparison with itself -// if locale == baseLocale { continue } -// -// // Verify locale is present in both catalogs -// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog") -// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog") -// -// // Verify locale has same number of keys as base locale -// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set() -// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)") -// -// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set() -// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)") -// } -// } -// -// // MARK: - Assertions -// -// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) { -// let baseLocale = context.baseLocale -// guard let baseKeys = context.keysByLocale[baseLocale] else { -// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line) -// } -// -// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) { -// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line) -// } -// } -// -// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) { -// let baseLocale = context.baseLocale -// guard let baseSignatures = context.placeholderSignature[baseLocale] else { -// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line) -// } -// -// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) { -// guard locale != baseLocale else { continue } -// for key in baseSignatures.keys.sorted() { -// guard let baseMap = baseSignatures[key] else { -// continue -// } -// guard let localeMap = localeSignatures[key] else { -// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line) -// } -// for path in baseMap.keys.sorted() { -// let expected = normalizedPlaceholders(baseMap[path, default: []]) -// let actual = normalizedPlaceholders(localeMap[path, default: []]) -// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line) -// } -// for (localePath, localeTokens) in localeMap { -// guard baseMap[localePath] == nil else { continue } -// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else { -// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line) -// continue -// } -// let expected = normalizedPlaceholders(baseMap[fallback, default: []]) -// let actual = normalizedPlaceholders(localeTokens) -// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line) -// } -// } -// } -// } -// -// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) { -// for key in keys { -// guard let entry = context.catalog.strings[key] else { -// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line) -// continue -// } -// for locale in context.locales.sorted() { -// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else { -// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line) -// continue -// } -// let segments = gatherSegments(from: unit) -// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line) -// for segment in segments { -// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines) -// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line) -// } -// } -// } -// } -// -// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) { -// guard let entry = context.catalog.strings[key] else { -// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line) -// return -// } -// -// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else { -// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line) -// return -// } -// -// // For simple strings (non-pluralized) -// if let actualValue = unit.value { -// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line) -// } else { -// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line) -// } -// } -// -// // MARK: - Loading -// -// private func loadContext(relativePath: String) throws -> CatalogContext { -// let catalog = try loadCatalog(relativePath: relativePath) -// let locales = catalog.locales -// let baseLocale = catalog.sourceLanguage -// var keysByLocale: [String: Set] = [:] -// var placeholderSignature: [String: [String: [String: [String]]]] = [:] -// -// for locale in locales { -// var localeKeys: Set = [] -// var localePlaceholders: [String: [String: [String]]] = [:] -// for (key, entry) in catalog.strings { -// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else { -// continue -// } -// localeKeys.insert(key) -// let segments = gatherSegments(from: unit) -// var pathMap: [String: [String]] = [:] -// for segment in segments { -// pathMap[segment.path] = placeholders(in: segment.value) -// } -// localePlaceholders[key] = pathMap -// } -// keysByLocale[locale] = localeKeys -// placeholderSignature[locale] = localePlaceholders -// } -// -// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature) -// } -// -// private func loadCatalog(relativePath: String) throws -> StringCatalog { -// let url = repoRootURL.appendingPathComponent(relativePath) -// let data = try Data(contentsOf: url) -// return try JSONDecoder().decode(StringCatalog.self, from: data) -// } -// -// private func loadPrimaryKeys() throws -> PrimaryKeyConfig { -// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json") -// let data = try Data(contentsOf: url) -// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data) -// } -// -// // MARK: - Regression Tests -// -// /// Helper method to access the app bundle for localization testing. -// /// This ensures tests use the actual app's Localizable.xcstrings instead of -// /// the test bundle, preventing false positives where localization keys are -// /// returned instead of translated strings. -// private func appBundle() -> Bundle { -// Bundle(for: ChatViewModel.self) -// } -// -// /// Tests to prevent regression of the pluralization format string issue -// /// These tests ensure that String(localized:) properly handles -// /// pluralized format strings with %#@variable@ syntax -// func testPluralizedFormatStringsDoNotCrash() { -// // These are the exact calls that were causing runtime errors -// -// // Test 1: content.accessibility.people_count with Int argument -// XCTAssertNoThrow({ -// let result = String( -// localized: "content.accessibility.people_count", -// bundle: appBundle(), -// comment: "Accessibility label announcing number of people in header" -// ) -// XCTAssertFalse(result.isEmpty, "Should return formatted string") -// // Verify we're getting actual localized content, not just the key -// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key") -// }(), "People count localization should not throw") -// -// // Test 2: location_notes.header with String and Int arguments -// XCTAssertNoThrow({ -// let result = String( -// localized: "location_notes.header", -// bundle: appBundle(), -// comment: "Header displaying the geohash and localized note count" -// ) -// XCTAssertFalse(result.isEmpty, "Should return formatted string") -// // Verify we're getting actual localized content, not just the key -// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key") -// }(), "Location notes header localization should not throw") -// } -// -// func testFormatStringArgumentMatching() { -// // Verify that the format strings properly handle their expected arguments -// -// // People count expects: %#@people@ format with Int -// let peopleResult = String( -// localized: "content.accessibility.people_count", -// bundle: appBundle(), -// comment: "Test" -// ) -// -// // Should not show format specifiers in the base string -// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string") -// -// // Location notes expects: #%@ • %#@note_count@ format with String, Int -// let notesResult = String( -// localized: "location_notes.header", -// bundle: appBundle(), -// comment: "Test" -// ) -// -// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string") -// } -// -// func testPluralizedStringsWithArguments() { -// // Test the pluralized strings with actual format arguments -// -// // Test people count with different values -// let testCases = [0, 1, 2, 5] -// -// for count in testCases { -// let result = String( -// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"), -// locale: .current, -// count -// ) -// -// // Just verify it doesn't crash and returns a reasonable string -// XCTAssertFalse(result.isEmpty, -// "People count should return non-empty string for count \(count)") -// XCTAssertTrue(result.contains("\(count)"), -// "Result should contain the count number for count \(count)") -// } -// } -// -// func testLocationNotesHeaderWithArguments() { -// let geohash = "abc123" -// let testCases = [0, 1, 2, 10] -// -// for count in testCases { -// let result = String( -// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"), -// locale: .current, -// geohash, count -// ) -// -// // Verify basic structure is maintained -// XCTAssertTrue(result.contains(geohash), -// "Result should contain geohash for count \(count)") -// XCTAssertTrue(result.contains("\(count)"), -// "Result should contain count for count \(count)") -// } -// } -// -// func testLocationChannelsRowTitleWithArguments() { -// let label = "test-channel" -// let testCases = [0, 1, 2, 5] -// -// for count in testCases { -// let result = String( -// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"), -// locale: .current, -// label, count -// ) -// -// // Verify basic structure is maintained -// XCTAssertTrue(result.contains(label), -// "Result should contain label for count \(count)") -// XCTAssertTrue(result.contains("\(count)"), -// "Result should contain count for count \(count)") -// } -// } -// -// func testNoArgumentsLocalization() { -// // Test that strings without arguments still work -// let result = String( -// localized: "common.ok", -// bundle: appBundle(), -// comment: "OK button text" -// ) -// -// XCTAssertFalse(result.isEmpty, "Simple localization should work") -// } -// -// func testLocalizationEdgeCases() { -// // Test edge cases that might cause issues -// -// // Large numbers -// let largeNumberResult = String( -// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"), -// locale: .current, -// 1000000 -// ) -// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers") -// -// // Zero -// let zeroResult = String( -// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"), -// locale: .current, -// 0 -// ) -// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero") -// -// // Empty geohash (edge case) -// let emptyGeohashResult = String( -// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"), -// locale: .current, -// "", 1 -// ) -// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash") -// } -//} -// -//// MARK: - Helpers -// -//private struct CatalogContext { -// let catalog: StringCatalog -// let locales: [String] -// let baseLocale: String -// let keysByLocale: [String: Set] -// let placeholderSignature: [String: [String: [String: [String]]]] -//} -// -//private struct StringCatalog: Decodable { -// let sourceLanguage: String -// let strings: [String: CatalogEntry] -// -// var locales: [String] { -// var localeSet: Set = [] -// for entry in strings.values { -// localeSet.formUnion(entry.localizations.keys) -// } -// return localeSet.sorted() -// } -//} -// -//private struct CatalogEntry: Decodable { -// let localizations: [String: CatalogLocalization] -//} -// -//private struct CatalogLocalization: Decodable { -// let stringUnit: CatalogStringUnit? -//} -// -//private struct CatalogStringUnit: Decodable { -// let state: String -// let value: String? -// let variations: CatalogVariations? -// let comment: String? -//} -// -//private struct CatalogVariations: Decodable { -// let plural: [String: [String: CatalogVariationValue]]? -//} -// -//private struct CatalogVariationValue: Decodable { -// let stringUnit: CatalogStringUnit? -//} -// -//private struct Segment { -// let components: [String] -// let value: String -// -// var path: String { -// components.isEmpty ? "base" : components.joined(separator: ".") -// } -//} -// -//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] { -// var segments: [Segment] = [] -// if let value = unit.value { -// segments.append(Segment(components: prefix, value: value)) -// } else if prefix.isEmpty { -// segments.append(Segment(components: [], value: "")) -// } -// if let plural = unit.variations?.plural { -// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) { -// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) { -// if let nested = variation.stringUnit { -// var nextPrefix = prefix -// nextPrefix.append("plural") -// nextPrefix.append(variable) -// nextPrefix.append(category) -// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix)) -// } -// } -// } -// } -// return segments -//} -// -//private func normalizedPlaceholders(_ tokens: [String]) -> [String] { -// tokens.sorted() -//} -// -//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? { -// let parts = localePath.split(separator: ".") -// guard parts.count == 3, parts.first == "plural" else { -// return nil -// } -// let variable = parts[1] -// let otherKey = "plural.\(variable).other" -// if baseMap[otherKey] != nil { -// return otherKey -// } -// let oneKey = "plural.\(variable).one" -// if baseMap[oneKey] != nil { -// return oneKey -// } -// return nil -//} -// -//private let placeholderRegex: NSRegularExpression = { -// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]" -// return try! NSRegularExpression(pattern: pattern, options: []) -//}() -// -//private func placeholders(in string: String) -> [String] { -// let range = NSRange(location: 0, length: (string as NSString).length) -// let matches = placeholderRegex.matches(in: string, options: [], range: range) -// var tokens: [String] = [] -// for match in matches { -// if let range = Range(match.range, in: string) { -// let token = String(string[range]) -// if token == "%%" { continue } -// tokens.append(token) -// } -// } -// return tokens -//} -// -//private struct PrimaryKeyConfig: Decodable { -// let app: [String] -// let shareExtension: [String] -// let expectedValues: [String: [String: String]]? -// let testLocales: [String]? -//}