From 9c84d4ce4f8e67af44b4d6df3d2ed417da6c3a82 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:12:27 +0200 Subject: [PATCH 01/14] Fail closed on unverifiable handshake peer IDs + check SecRandomCopyBytes results (#1645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening fixes from the repo evaluation: - NoiseSessionManager.authenticatedRemoteKey returned true for peer IDs that are neither 16-hex wire IDs nor full Noise-key IDs — an accept-any-key fallback kept for test harnesses. It now fails closed; the Noise/integration/E2E tests that relied on it address peers by key-derived wire IDs instead (the pattern NoiseCoverageTests already used), and a new regression test pins the rejection. - Four SecRandomCopyBytes call sites discarded the return status. The two verification nonces now fail their operation on error, the Nostr device identity seed uses CryptoKit key generation (cannot fail, and can no longer silently persist an all-zero seed), and BIP-340 aux randomness throws on failure like the adjacent nonce path. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Noise/NoiseSessionManager.swift | 6 +++--- bitchat/Nostr/NostrIdentityBridge.swift | 7 +++---- bitchat/Nostr/NostrProtocol.swift | 5 ++++- bitchat/Services/VerificationService.swift | 3 ++- .../ChatVerificationCoordinator.swift | 3 ++- .../EndToEnd/PrivateChatE2ETests.swift | 19 ++++++++++------- .../Integration/IntegrationTests.swift | 14 +++++++------ .../Integration/LargeTopologyTests.swift | 2 +- .../Integration/TestNetworkHelper.swift | 10 +++++---- bitchatTests/Noise/NoiseCoverageTests.swift | 20 ++++++++++++++++++ bitchatTests/Noise/NoiseProtocolTests.swift | 21 ++++++++++++------- 11 files changed, 75 insertions(+), 35 deletions(-) diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 6e2b3c23..c2aacbb0 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1126,8 +1126,8 @@ final class NoiseSessionManager { /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are /// also accepted by internal callers when they exactly match the static - /// key. Non-wire identifiers remain available to protocol test harnesses; - /// BLE packet ingress always supplies a short hexadecimal ID. + /// key. Anything else fails closed: an identifier that can't be checked + /// against the remote static key must never complete a handshake. private func authenticatedRemoteKey( _ remoteKey: Curve25519.KeyAgreement.PublicKey, matches claimedPeerID: PeerID @@ -1139,7 +1139,7 @@ final class NoiseSessionManager { if let claimedNoiseKey = claimedPeerID.noiseKey { return claimedNoiseKey == rawKey } - return true + return false } // MARK: - Encryption/Decryption diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index 2e18a235..51d14e84 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -76,10 +76,9 @@ final class NostrIdentityBridge { deviceSeedCache = existing return existing } - var seed = Data(count: 32) - _ = seed.withUnsafeMutableBytes { ptr in - SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) - } + // CryptoKit key generation cannot fail, unlike SecRandomCopyBytes — + // a discarded failure here would persist an all-zero identity seed. + let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) } // Ensure availability after first unlock to prevent unintended rotation when locked keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) deviceSeedCache = seed diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index bfef85e2..2e3e35d8 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -836,9 +836,12 @@ struct NostrEvent: Codable { // Sign with Schnorr (BIP-340) var messageBytes = [UInt8](eventIdHash) var auxRand = [UInt8](repeating: 0, count: 32) - _ = auxRand.withUnsafeMutableBytes { ptr in + let auxStatus = auxRand.withUnsafeMutableBytes { ptr in SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) } + guard auxStatus == errSecSuccess else { + throw NostrError.cryptographicFailure + } let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index ae983cca..8aa46ed1 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -84,7 +84,8 @@ final class VerificationService { let signKey = transport.noiseSigningPublicKeyData().hexEncodedString() let ts = Int64(Date().timeIntervalSince1970) var nonce = Data(count: 16) - _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + guard status == errSecSuccess else { return nil } let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "") let msg = payload.canonicalBytes() diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index f6499988..42b39a94 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -294,7 +294,8 @@ final class ChatVerificationCoordinator { } var nonce = Data(count: 16) - _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } + guard status == errSecSuccess else { return false } var pending = PendingVerification( noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, diff --git a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift index dc8de7ab..d3bb2ab9 100644 --- a/bitchatTests/EndToEnd/PrivateChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PrivateChatE2ETests.swift @@ -115,13 +115,18 @@ struct PrivateChatE2ETests { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) - + + // Manager sessions are keyed by key-derived wire IDs: handshake + // completion fails closed on IDs the remote key can't vouch for. + let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation) + let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) + // Establish encrypted session do { - let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID) - let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)! - let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)! - _ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3) + let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID) + let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)! + let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)! + _ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3) } catch { Issue.record("Failed to establish Noise session: \(error)") } @@ -134,7 +139,7 @@ struct PrivateChatE2ETests { let message = BitchatMessage(packet.payload), message.isPrivate { do { - let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID) + let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID) let encryptedPacket = BitchatPacket( type: 0x02, // Encrypted message type senderID: packet.senderID, @@ -155,7 +160,7 @@ struct PrivateChatE2ETests { // Decrypt incoming encrypted messages if packet.type == 0x02 { do { - let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID) + let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID) if let message = BitchatMessage(decrypted) { #expect(message.content == TestConstants.testMessage1) #expect(message.isPrivate) diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index e07f0fae..70311b49 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -18,10 +18,10 @@ struct IntegrationTests { private var helper = TestNetworkHelper() init() { - helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString)) - helper.createNode("David", peerID: PeerID(str: UUID().uuidString)) + helper.createNode("Alice") + helper.createNode("Bob") + helper.createNode("Charlie") + helper.createNode("David") } // MARK: - Multi-Peer Scenarios @@ -266,9 +266,11 @@ struct IntegrationTests { helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob") } - // Simulate Bob restart by recreating his Noise manager + // Simulate Bob restart by recreating his Noise manager. A new static + // key means a new key-derived wire ID, just like production. let bobKey = Curve25519.KeyAgreement.PrivateKey() helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain) + helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation) // Re-establish Noise handshake explicitly via managers do { @@ -320,7 +322,7 @@ struct IntegrationTests { @Test func largeScaleNetwork() async throws { // Create larger network for i in 5...10 { - helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)")) + helper.createNode("Node\(i)") } // Connect in ring topology with cross-connections diff --git a/bitchatTests/Integration/LargeTopologyTests.swift b/bitchatTests/Integration/LargeTopologyTests.swift index 3f4c44dc..a87be438 100644 --- a/bitchatTests/Integration/LargeTopologyTests.swift +++ b/bitchatTests/Integration/LargeTopologyTests.swift @@ -37,7 +37,7 @@ struct LargeTopologyTests { private func makeNodes(_ names: [String]) { for name in names { - helper.createNode(name, peerID: PeerID(str: UUID().uuidString)) + helper.createNode(name) } } diff --git a/bitchatTests/Integration/TestNetworkHelper.swift b/bitchatTests/Integration/TestNetworkHelper.swift index 0131739c..e11d1917 100644 --- a/bitchatTests/Integration/TestNetworkHelper.swift +++ b/bitchatTests/Integration/TestNetworkHelper.swift @@ -22,15 +22,17 @@ final class TestNetworkHelper { // MARK: - Node/Manager management @discardableResult - func createNode(_ name: String, peerID: PeerID) -> MockBLEService { + func createNode(_ name: String) -> MockBLEService { let node = MockBLEService(bus: bus) - node.myPeerID = peerID + // Wire IDs must derive from the node's Noise static key: handshake + // completion fails closed on IDs the remote key can't vouch for. + let key = Curve25519.KeyAgreement.PrivateKey() + node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation) node.mockNickname = name nodes[name] = node - + // This synchronous helper directly drives all three XX messages and // has no transport callback loop for delayed collision recovery. - let key = Curve25519.KeyAgreement.PrivateKey() noiseManagers[name] = NoiseSessionManager( localStaticKey: key, keychain: mockKeychain, diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index d083bd47..763f5816 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -578,6 +578,26 @@ struct NoiseCoverageTests { #expect(failingManager.getSession(for: charliePeerID) == nil) } + @Test("Handshake completion fails closed on non-wire peer IDs") + func handshakeCompletionRejectsNonWirePeerIDs() throws { + let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) + let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) + + // Alice addresses Bob by an identifier no static key can vouch for: + // neither a 16-hex wire ID nor a full Noise-key ID. Completion must + // reject it rather than accept any remote static key. + let nonWireID = PeerID(str: "not-a-wire-identifier") + let msg1 = try aliceManager.initiateHandshake(with: nonWireID) + let msg2 = try #require( + try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1) + ) + + #expect(throws: (any Error).self) { + try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2) + } + #expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true) + } + @Test("Session manager cleans up initiator sessions after start-handshake failures") func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() { let manager = NoiseSessionManager( diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index ac8ef93d..007bbef0 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -68,22 +68,29 @@ struct NoiseProtocolTests { private let bobKey = Curve25519.KeyAgreement.PrivateKey() private let mockKeychain = MockKeychain() - private let alicePeerID = PeerID(str: UUID().uuidString) - private let bobPeerID = PeerID(str: UUID().uuidString) - + // Manager sessions are keyed by the remote peer. Keep the historical + // names, but derive each wire ID from the static key that the + // corresponding manager authenticates during the handshake. + private var alicePeerID: PeerID { + PeerID(publicKey: bobKey.publicKey.rawRepresentation) + } + private var bobPeerID: PeerID { + PeerID(publicKey: aliceKey.publicKey.rawRepresentation) + } + private let aliceSession: NoiseSession private let bobSession: NoiseSession - + init() { aliceSession = NoiseSession( - peerID: alicePeerID, + peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey ) - + bobSession = NoiseSession( - peerID: bobPeerID, + peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation), role: .responder, keychain: mockKeychain, localStaticKey: bobKey From 2a9fb4d53f67beed91064eb97fa7f2cdb1f17dfb Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:12:30 +0200 Subject: [PATCH 02/14] Make SwiftLint blocking in CI now that the violation backlog is zero (#1646) * Make SwiftLint blocking in CI now that the violation backlog is zero The lint job has been advisory since #1361, with the backlog tracked in #1088. That backlog is now down to 28 violations, so this clears it and flips the job to enforcing instead of adding baseline machinery: - Fix all remaining violations: closure parameters/capture lists joined onto their opening-brace line (BLEService, NoiseSessionManager, ChatMediaTransferCoordinator, BLENoisePacketHandlerTests), one trailing comma, one opening-brace spacing (both via swiftlint --fix). - Move the two periphery:ignore comments above the doc blocks they were orphaning; verified with a periphery --strict scan that the commands still register (no new findings vs baseline). - Disable the todo rule: the single TODO cites tracked issue #1434 and is a deliberate marker, not lint debt. - Exclude .device-lab from local runs (untracked; CI never sees it). - CI: drop continue-on-error and the "(advisory)" label; run with --strict so any new warning fails the job. Full SwiftPM suite green (2,004 tests / 216 suites) after the formatting changes; swiftlint lint --strict exits 0 with the same 0.65.0 the CI container pins. Co-Authored-By: Claude Fable 5 * Fix is_disjoint violation introduced by #1542 The strict lint gate caught this on its first run: the mention-suggestion key handler used modifierFlags.intersection([...]).isEmpty, which is exactly the pattern isDisjoint(with:) expresses directly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .github/workflows/swift-tests.yml | 13 +++++++------ .swiftlint.yml | 2 ++ bitchat/Noise/NoiseSessionManager.swift | 3 +-- bitchat/Services/BLE/BLEService.swift | 15 +++++---------- bitchat/Utils/QuickJoinRegions.swift | 2 +- .../ViewModels/ChatMediaTransferCoordinator.swift | 3 +-- bitchat/Views/ContentComposerView.swift | 2 +- ...ChatMediaTransferCoordinatorContextTests.swift | 3 +-- .../Services/BLENoisePacketHandlerTests.swift | 6 ++---- .../Sources/BitFoundation/AnnounceV2Packet.swift | 6 +++--- .../Sources/BitFoundation/PeerIDRotation.swift | 8 ++++---- 11 files changed, 28 insertions(+), 35 deletions(-) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 81c42eb2..e64683de 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -264,11 +264,13 @@ jobs: CODE_SIGNING_ALLOWED=NO \ test - # Advisory only: SwiftLint reports style violations without ever failing the - # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so - # it can never break the documented xcodebuild path or block a merge. + # Blocking since the backlog reached zero (all violations fixed; rules the + # codebase deliberately breaks are disabled in .swiftlint.yml). --strict + # promotes warnings to errors so new violations fail the job. Runs in a + # pinned container (no Xcode plugin, no pbxproj changes) so it can never + # break the documented xcodebuild path. lint: - name: SwiftLint (advisory) + name: SwiftLint runs-on: ubuntu-latest timeout-minutes: 15 # This job runs a third-party container image, so give it the least @@ -280,10 +282,9 @@ jobs: # Tag for readability, digest for immutability (tags can be repointed). # Bump both together, deliberately — never a floating tag. image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e - continue-on-error: true steps: - uses: actions/checkout@v5 with: persist-credentials: false - name: Run SwiftLint - run: swiftlint lint --reporter github-actions-logging + run: swiftlint lint --strict --reporter github-actions-logging diff --git a/.swiftlint.yml b/.swiftlint.yml index 833662e9..8d916f35 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -3,6 +3,7 @@ excluded: - .build - .claude + - .device-lab - .swiftpm - .DerivedData - DerivedData @@ -21,6 +22,7 @@ disabled_rules: - control_statement - void_function_in_ternary - redundant_discardable_let # SwiftUI breaks without it + - todo # TODOs that cite a tracked issue are deliberate markers, not lint debt # To be enabled as we fix the issues - trailing_whitespace - cyclomatic_complexity diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index c2aacbb0..80b3cc42 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -1001,8 +1001,7 @@ final class NoiseSessionManager { 0, recentInitiatorCompletionGracePeriod - elapsed ) - let timeout = DispatchWorkItem(flags: .barrier) { - [weak self, weak establishedSession] in + let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in guard let self, let establishedSession, let current = self.sessions[peerID], diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0c4ecfb1..5f9b97af 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -4027,10 +4027,8 @@ extension BLEService { ) } } - service.onRekeyHandshakeReady = { - [weak self, weak service] peerID, initiation in - self?.messageQueue.async { - [weak self, weak service] in + service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in + self?.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -4046,14 +4044,12 @@ extension BLEService { self.broadcastNoiseHandshake(message, to: peerID) } } - service.onHandshakeRecoveryRequired = { - [weak self, weak service] request in + service.onHandshakeRecoveryRequired = { [weak self, weak service] request in guard let self, let service else { return } #if DEBUG self._test_beforeHandshakeRecoveryEnqueued?(request.peerID) #endif - self.messageQueue.async { - [weak self, weak service] in + self.messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service else { @@ -5294,8 +5290,7 @@ extension BLEService { ) else { return } - messageQueue.async { - [weak self, weak service] in + messageQueue.async { [weak self, weak service] in guard let self, let service, self.noiseService === service, diff --git a/bitchat/Utils/QuickJoinRegions.swift b/bitchat/Utils/QuickJoinRegions.swift index 31a6fae6..149aa8d3 100644 --- a/bitchat/Utils/QuickJoinRegions.swift +++ b/bitchat/Utils/QuickJoinRegions.swift @@ -108,6 +108,6 @@ struct QuickJoinSuggestion { "UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd", "VE": "d9", "VI": "de", "VN": "w7", "VU": "rs", "WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj", - "ZA": "ke", "ZM": "kt", "ZW": "ks", + "ZA": "ke", "ZM": "kt", "ZW": "ks" ] } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 84295135..fe314f62 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -1859,8 +1859,7 @@ private extension ChatMediaTransferCoordinator { TimeInterval(UInt64.max) / 1_000_000_000 ) * 1_000_000_000 ) - reconnectRetryExpiryTasks[messageID] = Task { - @MainActor [weak self] in + reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in if nanoseconds > 0 { try? await Task.sleep(nanoseconds: nanoseconds) } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 64021d52..356c4141 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -482,7 +482,7 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier { /// list is up. Inactive monitors pass everything through. private func handleKeyDown(_ event: NSEvent) -> NSEvent? { guard isActive(), - event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { + event.modifierFlags.isDisjoint(with: [.command, .option, .control]) else { return event } diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index ad200113..1969602c 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -653,8 +653,7 @@ struct ChatMediaTransferCoordinatorContextTests { @Test @MainActor func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit() - async throws - { + async throws { let context = MockChatMediaTransferContext() let peerID = PeerID(str: "1122334455667788") context.selectedPrivateChatPeer = peerID diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index cdfa6f81..e2b80def 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -150,14 +150,12 @@ struct BLENoisePacketHandlerTests { recorder.clearedSessions.append(peerID) service.clearSession(for: peerID) }, - handleAuthenticatedPeerState: { - peerID, payload, generation in + handleAuthenticatedPeerState: { peerID, payload, generation in recorder.authenticatedPeerStates.append( (peerID, payload, generation) ) }, - deliverNoisePayload: { - peerID, type, payload, timestamp in + deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append( (peerID, type, payload, timestamp) ) diff --git a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift index 0bee3e6f..72b2fd14 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift @@ -8,6 +8,9 @@ import Foundation +// periphery:ignore - intentionally unreferenced by production code; nothing +// emits or consumes this type yet, and BLEService parses it only to ignore it. +// Delete this annotation when the mesh starts using it. /// Identity-free presence announcement for rotating peer IDs. /// /// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the @@ -37,9 +40,6 @@ import Foundation /// `docs/PEER-ID-ROTATION.md`. /// /// Not emitted or consumed by the shipping mesh yet. -// periphery:ignore - intentionally unreferenced by production code; nothing -// emits or consumes this type yet, and BLEService parses it only to ignore it. -// Delete this annotation when the mesh starts using it. public struct AnnounceV2Packet: Equatable, Sendable { /// Rotation epoch this announce was built for. Carried explicitly so a /// receiver matches against a stated epoch instead of guessing. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift index 17a079d9..2edfa951 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift @@ -9,6 +9,10 @@ import Foundation private import CryptoKit +// periphery:ignore - intentionally unreferenced by production code. These are +// the reviewable primitives for a protocol change that cannot ship until both +// platforms agree on it; wiring them into the transport is the next step, not +// this one. Delete this annotation when the mesh starts using them. /// Derivations for rotating peer IDs and pairwise recognition tags. /// /// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the @@ -32,10 +36,6 @@ private import CryptoKit /// Recognition tags come from the X25519 shared secret between two static /// keys, so exactly two parties can compute a given tag and an observer can /// compute none of them. -// periphery:ignore - intentionally unreferenced by production code. These are -// the reviewable primitives for a protocol change that cannot ship until both -// platforms agree on it; wiring them into the transport is the next step, not -// this one. Delete this annotation when the mesh starts using them. public enum PeerIDRotation { // MARK: - Parameters From 6f961638f2eec16e4e3ab90fd7ef3fbf571686f8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:00 +0200 Subject: [PATCH 03/14] Timestamp sanity windows: future-dated QR codes and implausible Nostr DM rumors (#1647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two re-assessment findings: - verifyScannedQR checked only staleness (now - ts > maxAge), so a future-dated timestamp bought a QR a longer validity window than a fresh one. The freshness check is now symmetric. - Inbound Nostr DMs had no client-side created_at validation; the age bound relied entirely on relays honoring the subscription's `since` filter. Both gift-wrap decrypt paths now drop rumors outside [now - lookback - skew, now + skew]. The inner rumor timestamp is the sender's true send time (only the outer gift wrap is randomized per NIP-17), so the window mirrors exactly what an honest relay already guarantees — a dishonest relay can no longer inject stale or future-dated DMs. Existing mitigations (persistent gift-wrap dedup, relay-side since) are unchanged; this closes the malicious-relay gap. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Services/TransportConfig.swift | 4 +++ bitchat/Services/VerificationService.swift | 5 +-- bitchat/ViewModels/NostrInboundPipeline.swift | 28 ++++++++++++++++ .../NostrInboundPipelineTimestampTests.swift | 32 +++++++++++++++++++ .../Services/VerificationServiceTests.swift | 13 ++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 bitchatTests/NostrInboundPipelineTimestampTests.swift diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 98029094..b1e19701 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -216,6 +216,10 @@ enum TransportConfig { static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 + // Tolerated clock skew for the client-side rumor-timestamp window on + // inbound Nostr DMs (senders stamp the inner rumor with real time; only + // the outer gift wrap is randomized per NIP-17). + static let nostrDMMaxClockSkewSeconds: TimeInterval = 900 // A sampled chat message this recent means "a conversation is happening // there" for the empty-timeline nearby-activity hint. static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 diff --git a/bitchat/Services/VerificationService.swift b/bitchat/Services/VerificationService.swift index 8aa46ed1..26247379 100644 --- a/bitchat/Services/VerificationService.swift +++ b/bitchat/Services/VerificationService.swift @@ -106,9 +106,10 @@ final class VerificationService { /// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks) func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? { guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil } - // Freshness + // Freshness, in both directions: a future-dated timestamp must not + // buy a QR a longer validity window than a fresh one gets. let now = Date().timeIntervalSince1970 - if now - Double(qr.ts) > maxAge { return nil } + if abs(now - Double(qr.ts)) > maxAge { return nil } // Verify signature using embedded ed25519 signKey guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil } guard let transport = transport else { return nil } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 85024436..a6592802 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -357,6 +357,13 @@ final class NostrInboundPipeline { return } + guard Self.isPlausibleRumorTimestamp(rumorTs) else { + if verbose { + SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) + } + return + } + if verbose { SecureLogger.debug( "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", @@ -445,6 +452,11 @@ final class NostrInboundPipeline { recipientIdentity: currentIdentity ) + guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else { + SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session) + return + } + if content.hasPrefix("verify:") { return } @@ -542,6 +554,22 @@ final class NostrInboundPipeline { } } +extension NostrInboundPipeline { + /// Client-side mirror of the relay-side `since` filter on DM + /// subscriptions: a relay that ignores `since` — or replays archived + /// events — must not inject stale or future-dated DMs. The inner rumor + /// timestamp is the sender's true send time (only the outer gift wrap + /// is randomized per NIP-17), so the plausible window is the + /// subscription lookback plus tolerated clock skew on both ends. + /// Internal (not private) so tests can pin the window directly. + static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - TimeInterval(ts) + return age >= -TransportConfig.nostrDMMaxClockSkewSeconds + && age <= TransportConfig.nostrDMSubscribeLookbackSeconds + + TransportConfig.nostrDMMaxClockSkewSeconds + } +} + private extension NostrInboundPipeline { @MainActor static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { diff --git a/bitchatTests/NostrInboundPipelineTimestampTests.swift b/bitchatTests/NostrInboundPipelineTimestampTests.swift new file mode 100644 index 00000000..187133be --- /dev/null +++ b/bitchatTests/NostrInboundPipelineTimestampTests.swift @@ -0,0 +1,32 @@ +// +// NostrInboundPipelineTimestampTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +struct NostrInboundPipelineTimestampTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let nowSeconds = 1_700_000_000 + private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds) + private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds) + + @Test("Rumor timestamps inside the lookback-plus-skew window are accepted") + func acceptsPlausibleTimestamps() { + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now)) + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now)) + // A sender clock slightly ahead of the receiver is tolerated. + #expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now)) + } + + @Test("Future-dated and stale rumor timestamps are rejected") + func rejectsImplausibleTimestamps() { + #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now)) + #expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now)) + } +} diff --git a/bitchatTests/Services/VerificationServiceTests.swift b/bitchatTests/Services/VerificationServiceTests.swift index b8f688d3..ce854a58 100644 --- a/bitchatTests/Services/VerificationServiceTests.swift +++ b/bitchatTests/Services/VerificationServiceTests.swift @@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase { XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) } + func test_verifyScannedQR_rejectsFutureDatedPayload() throws { + let (service, noise) = makeService() + let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970) + let qrString = try makeSignedQR( + noise: noise, + nickname: "future-\(UUID().uuidString)", + npub: nil, + ts: futureTimestamp + ) + + XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60)) + } + func test_verifyScannedQR_rejectsTamperedSignature() throws { let (service, noise) = makeService() let badSignature = Data(repeating: 0xAA, count: 64) From f0249d9cd71d0f206f65430ad2707bfa4d5be70a Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:03 +0200 Subject: [PATCH 04/14] Make the Periphery scan blocking (#1648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job shipped with its own promotion condition — "drop continue-on-error once the baseline proves stable" — and the baseline has now been stable across a month of merges since it landed July 8 (#1410). Known macOS-scan false positives stay suppressed by the committed baseline; --strict already fails the step on NEW dead code, so the only change is that the failure now blocks instead of annotating. Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .github/workflows/periphery.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/periphery.yml b/.github/workflows/periphery.yml index e33fc7c2..b5367338 100644 --- a/.github/workflows/periphery.yml +++ b/.github/workflows/periphery.yml @@ -11,9 +11,11 @@ jobs: name: Periphery scan runs-on: macos-latest timeout-minutes: 30 - # Advisory, like SwiftLint (#1361): findings annotate the PR but don't - # block merges. Drop continue-on-error once the baseline proves stable. - continue-on-error: true + # Blocking, like SwiftLint since #1646. The baseline (committed July 8) + # has been stable across a month of merges — the "drop continue-on-error + # once the baseline proves stable" condition this job shipped with is met. + # Known macOS-scan false positives stay suppressed by the baseline; only + # NEW dead code fails the job (--strict below). steps: - name: Checkout code From 6ef39451798ac93b0306882a23444a242e8d968f Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:56:12 +0200 Subject: [PATCH 05/14] Deflake gift-wrap tests: settle deadlines, not latency budgets (#1651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deflake gift-wrap tests: settle deadlines, not latency budgets The gift-wrap round-trip tests failed three CI runs this week (handleGiftWrap_privateMessageStoresConversationAndMapping, handleGiftWrap_deliveredAckUpdatesExistingMessage, handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates), each with the same signature: the async NIP-17 unwrap missed a 5s wait on a loaded runner. 40 local iterations of both suites pass clean — the failures are scheduler starvation, exactly the class TestConstants.settleTimeout documents. Every positive wait in ChatViewModelExtensionsTests and ChatNostrCoordinatorContextTests now uses settleTimeout (30s): the explicit 5.0s literals on the gift-wrap waits, the longTimeout media waits, and the bare-default channel-switch waits. All are expected-true waits, so passing runs return immediately and never pay the deadline. Co-Authored-By: Claude Fable 5 * Deflake SimulatedMesh TTL budget: settle discovery before the baseline publicMessageRelaysAcrossLineTopologyWithinTTLBudget snapshotted its frame baseline after a single 2s advance, but discovery is not quiet by then: every first-seen peer schedules an afterglow re-announce at a random 0.3-0.6s delay (BLEAnnounceHandler), and each of those can cascade another relay round. Whether that traffic lands before or after the snapshot depends on the draw — CI measured the "single message" at 14 and 18 frames against a budget of 12. The test now advances until the mesh goes a full window with no new frames before taking the baseline, so the budget only ever measures the message under test. 30 local iterations green. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- .../ChatNostrCoordinatorContextTests.swift | 4 +-- .../ChatViewModelExtensionsTests.swift | 34 +++++++++---------- .../Simulation/SimulatedMeshTests.swift | 16 ++++++++- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 80d249c4..6200d4bf 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests { // The NIP-17 unwrap runs off the main actor; wait for the hop back. let convKey = PeerID(nostr_: sender.publicKeyHex) - let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) #expect(routed) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) @@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests { // The pipeline itself stays usable: a gift wrap spawned AFTER the // wipe (new generation) still decrypts and delivers. coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) - let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) + let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout) #expect(delivered) } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index a1d12550..6f9609ee 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests { LocationChannelManager.shared.select(channel) defer { LocationChannelManager.shared.select(.mesh) } - _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }) + _ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout) let (viewModel, _) = makeTestableViewModel() - _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) + _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout) let signer = try NostrIdentity.generate() let event = NostrEvent( @@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests { viewModel.handleNostrEvent(signed) } return false - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didAppend) } @@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests { let didStore = await TestHelpers.waitUntil( { viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didStore) #expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex) @@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests { // (sent even for blocked senders) to know processing finished. let didAck = await TestHelpers.waitUntil( { viewModel.sentGeoDeliveryAcks.contains(messageID) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didAck) #expect(viewModel.privateChats[convKey] == nil) @@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests { let didUpdate = await TestHelpers.waitUntil( { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, - timeout: 5.0 + timeout: TestConstants.settleTimeout ) #expect(didUpdate) } @@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests { viewModel.sendVoiceNote(at: url) // Media sends hop through Task.detached; the global executor is - // shared with every parallel test worker, so a loaded runner can - // exceed the 5s default. waitUntil returns as soon as the condition - // holds, so passing runs never pay the longer timeout. - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) + // shared with every parallel test worker, so a loaded runner can be + // starved for seconds. waitUntil returns as soon as the condition + // holds, so passing runs never pay the settle deadline. + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) @@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests { viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true) let showedSecond = await TestHelpers.waitUntil( { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) #expect(showedSecond) let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) @@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests { ) let advanced = await TestHelpers.waitUntil( { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, - timeout: TestConstants.longTimeout + timeout: TestConstants.settleTimeout ) #expect(advanced) #expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send") @@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests { let didFail = await TestHelpers.waitUntil({ isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didFail) #expect(!FileManager.default.fileExists(atPath: url.path)) #expect(transport.sentPrivateFiles.isEmpty) @@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests { viewModel.selectedPrivateChatPeer = peerID viewModel.sendImage(from: sourceURL) - let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) + let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout) #expect(didSend) #expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") @@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests { let didNotify = await TestHelpers.waitUntil({ viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) - }, timeout: TestConstants.longTimeout) + }, timeout: TestConstants.settleTimeout) #expect(didNotify) #expect(transport.sentPrivateFiles.isEmpty) #expect(viewModel.privateChats[peerID]?.isEmpty != false) diff --git a/bitchatTests/Simulation/SimulatedMeshTests.swift b/bitchatTests/Simulation/SimulatedMeshTests.swift index c03ed21f..b4630076 100644 --- a/bitchatTests/Simulation/SimulatedMeshTests.swift +++ b/bitchatTests/Simulation/SimulatedMeshTests.swift @@ -56,7 +56,21 @@ struct SimulatedMeshTests { mesh.connect(1, 2) mesh.announceAll() - mesh.advanceTime(by: 2) + // Discovery is not quiet after one advance: every first-seen peer + // schedules an afterglow re-announce at a RANDOM 0.3–0.6s delay + // (BLEAnnounceHandler), and each of those can cascade another relay + // round. Whether that traffic lands before or after a one-shot + // baseline snapshot depends on the draw — the budget assertion below + // flaked on CI at 14 and 18 frames for exactly that reason. Advance + // until the mesh goes a full window with no new frames, so the + // baseline only ever measures the message under test. + var settled = mesh.deliveredFrameCount + for _ in 0..<20 { + mesh.advanceTime(by: 2) + let now = mesh.deliveredFrameCount + if now == settled { break } + settled = now + } let baseline = mesh.deliveredFrameCount let capture = TransportEventCapture() From 4122a4dacde9e94348325aa4b12c48ccf33d290c Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:56:33 +0200 Subject: [PATCH 06/14] Stop claiming encryption and privacy properties that aren't live (#1595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop claiming encryption and privacy properties that aren't live Two of the app's most safety-critical signals could show more security than actually existed: 1. getEncryptionStatus was sticky-optimistic: any peer whose fingerprint was ever persisted resolved to noiseSecured/noiseVerified even when the live Noise session was .none, .handshaking, or .failed. After a cold launch or a handshake failure the DM header showed a solid lock and the composer caption claimed "end-to-end encrypted" with no secure session in existence. The status now reflects only the live session; a remembered fingerprint decides what an established session upgrades to (verified vs secured), nothing more. Peers reachable only over Nostr keep the encrypted caption — gift-wrapped delivery is end-to-end without a Noise session. 2. Privacy copy told three lies (all 30 locales fixed): - "ephemeral identity — new peer ID generated regularly": the peer ID is stable until a panic wipe (rotation is spec-only). Now "local-only identity — your identity is a keypair created on this device; panic wipe replaces it instantly." - "no servers, accounts, or data collection": location channels and internet delivery go through Nostr relays. Now "no accounts, no phone numbers, no data collection." - location channels: "your IP address is hidden by routing all traffic over tor" — tor is a toggle. The description now says tor is the default, and the sheet shows the existing red tor-off warning at the point of joining whenever the toggle is off. Co-Authored-By: Claude Fable 5 * Review fixes: gate nostr availability on a stored recipient key - Codex P2 / review: a mutual favorite with a nil peerNostrPublicKey still reported .nostrAvailable, so the DM caption could claim "end-to-end encrypted" with neither a Noise session nor a usable Nostr key. BitchatPeer.connectionState (and the header fallback in PrivateConversationModel) now require a stored recipient key — mirroring NostrTransport's reachability rule. Regression test: mutual + nil key → .offline; key present → .nostrAvailable. - Copy: "local-only identity" → "device-local identity" (all 30 locales) — once a fingerprint or QR is shared it isn't "only" local; device-local says where the keys live without over-claiming. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/PrivateConversationModels.swift | 5 +- bitchat/Localizable.xcstrings | 240 +++++++++--------- bitchat/Models/BitchatPeer.swift | 13 +- .../ChatPeerIdentityCoordinator.swift | 14 +- bitchat/Views/ContentSheetViews.swift | 7 +- bitchat/Views/LocationChannelsSheet.swift | 13 + bitchatTests/BitchatPeerTests.swift | 24 ++ ...tPeerIdentityCoordinatorContextTests.swift | 31 +++ 8 files changed, 218 insertions(+), 129 deletions(-) diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index a4e89149..80d5cf29 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject { } if let noiseKey = Data(hexString: headerPeerID.id), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.isMutual { + favoriteStatus.isMutual, + // No stored recipient key means no Nostr delivery — and the + // "end-to-end encrypted" caption keys off .nostrAvailable. + favoriteStatus.peerNostrPublicKey != nil { return .nostrAvailable } if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f869ab85..b05370a7 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -11375,181 +11375,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "يُولد معرف قرين جديد بانتظام" + "value" : "هويتك زوج مفاتيح يُنشأ على هذا الجهاز — المسح الطارئ يستبدلها فورًا" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "নিয়মিত নতুন পিয়ার আইডি তৈরি হয়" + "value" : "আপনার পরিচয় এই ডিভাইসে তৈরি একটি কী-জোড়া — প্যানিক ওয়াইপ সঙ্গে সঙ্গে এটি বদলে দেয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "neue peer-id wird regelmäßig erzeugt" + "value" : "deine identität ist ein schlüsselpaar, das auf diesem gerät erzeugt wird — der panik-löscher ersetzt es sofort" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "new peer ID generated regularly" + "value" : "your identity is a keypair created on this device — panic wipe replaces it instantly" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "nuevo ID de peer generado periódicamente" + "value" : "tu identidad es un par de claves creado en este dispositivo — el borrado de pánico la reemplaza al instante" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "شناسهٔ همتای جدید به‌طور منظم ساخته می‌شود" + "value" : "هویت شما یک جفت کلید است که روی این دستگاه ساخته می‌شود — پاک‌سازی اضطراری آن را فوراً جایگزین می‌کند" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "regular na lumilikha ng bagong peer ID" + "value" : "ang iyong pagkakakilanlan ay isang key pair na ginawa sa device na ito — agad itong pinapalitan ng panic wipe" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "nouvel id de pair généré régulièrement" + "value" : "votre identité est une paire de clés créée sur cet appareil — l'effacement d'urgence la remplace instantanément" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מזהה עמית חדש נוצר באופן קבוע" + "value" : "הזהות שלך היא צמד מפתחות שנוצר במכשיר הזה — מחיקת חירום מחליפה אותה מיד" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "नया पीयर आईडी नियमित रूप से बनाया जाता है" + "value" : "आपकी पहचान इस डिवाइस पर बनी एक कुंजी-जोड़ी है — पैनिक वाइप इसे तुरंत बदल देता है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "id peer baru dibuat secara berkala" + "value" : "identitas anda adalah pasangan kunci yang dibuat di perangkat ini — panic wipe langsung menggantinya" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "nuovo id peer generato regolarmente" + "value" : "la tua identità è una coppia di chiavi creata su questo dispositivo — la cancellazione di emergenza la sostituisce all'istante" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "新しいpeer idが定期的に生成されます" + "value" : "アイデンティティはこの端末上で生成される鍵ペアです — パニックワイプで即座に置き換えられます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새로운 피어 ID가 주기적으로 생성됩니다" + "value" : "신원은 이 기기에서 생성되는 키 쌍입니다 — 패닉 와이프로 즉시 교체됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "id peer baru dibuat secara berkala" + "value" : "identiti anda ialah pasangan kunci yang dicipta pada peranti ini — panic wipe menggantikannya serta-merta" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "नयाँ peer id नियमित रूपमा सिर्जना हुन्छ" + "value" : "तपाईंको परिचय यही यन्त्रमा बनेको कुञ्जी-जोडी हो — प्यानिक वाइपले तुरुन्तै बदल्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "nieuw peer-ID wordt regelmatig aangemaakt" + "value" : "je identiteit is een sleutelpaar dat op dit apparaat wordt gemaakt — de paniekwis vervangt het direct" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "nowe ID peera generowane regularnie" + "value" : "twoja tożsamość to para kluczy utworzona na tym urządzeniu — wymazanie awaryjne natychmiast ją zastępuje" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "novo ID de par gerado regularmente" + "value" : "a tua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico substitui-a de imediato" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "novo id de peer gerado regularmente" + "value" : "sua identidade é um par de chaves criado neste dispositivo — a limpeza de pânico a substitui na hora" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "новый id пира создаётся регулярно" + "value" : "ваша идентичность — пара ключей, созданная на этом устройстве. экстренное стирание мгновенно её заменяет" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "nytt peer-ID skapas regelbundet" + "value" : "din identitet är ett nyckelpar som skapas på den här enheten — panikradering ersätter det direkt" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "புதிய peer ID வழக்கமாக உருவாக்கப்படுகிறது" + "value" : "உங்கள் அடையாளம் இந்தச் சாதனத்தில் உருவாக்கப்படும் விசை-இணை — பேனிக் அழிப்பு உடனே அதை மாற்றும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "สร้างรหัสเพียร์ใหม่เป็นระยะ" + "value" : "ตัวตนของคุณคือคู่กุญแจที่สร้างบนเครื่องนี้ — panic wipe จะแทนที่ทันที" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "yeni eş kimliği düzenli olarak oluşturulur" + "value" : "kimliğiniz bu cihazda oluşturulan bir anahtar çiftidir — panik silme onu anında değiştirir" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "новий id піра генерується регулярно" + "value" : "ваша ідентичність — пара ключів, створена на цьому пристрої. екстрене стирання миттєво її замінює" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "نیا peer ID باقاعدگی سے بنایا جاتا ہے" + "value" : "آپ کی شناخت اس ڈیوائس پر بنایا گیا کلیدی جوڑا ہے — پینک وائپ اسے فوراً بدل دیتا ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "tạo ID nút mới định kỳ" + "value" : "danh tính của bạn là một cặp khoá được tạo trên thiết bị này — panic wipe thay thế nó ngay lập tức" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "定期生成新的 peer id" + "value" : "你的身份是在本机生成的密钥对 — 紧急清除会立即更换它" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "定期生成新的 peer id" + "value" : "你的身分是在本機產生的金鑰對 — 緊急清除會立即更換它" } } } @@ -11560,181 +11560,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "هوية مؤقتة" + "value" : "هوية محفوظة على الجهاز" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "অস্থায়ী পরিচয়" + "value" : "ডিভাইসে থাকা পরিচয়" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "flüchtige identität" + "value" : "gerätelokale identität" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "ephemeral identity" + "value" : "device-local identity" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "identidad efímera" + "value" : "identidad local del dispositivo" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "هویت موقت" + "value" : "هویت محلی دستگاه" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "panandaliang pagkakakilanlan" + "value" : "pagkakakilanlang nasa device" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "identité éphémère" + "value" : "identité locale à l'appareil" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "זהות זמנית" + "value" : "זהות מקומית במכשיר" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "अस्थायी पहचान" + "value" : "डिवाइस-स्थानीय पहचान" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "identitas sementara" + "value" : "identitas lokal perangkat" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "identità effimera" + "value" : "identità locale al dispositivo" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "一時的なアイデンティティ" + "value" : "端末ローカルのアイデンティティ" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "임시 신원" + "value" : "기기 로컬 신원" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "identitas sementara" + "value" : "identiti setempat peranti" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "क्षणिक पहिचान" + "value" : "यन्त्र-स्थानीय परिचय" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "tijdelijke identiteit" + "value" : "apparaat-lokale identiteit" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "tymczasowa tożsamość" + "value" : "tożsamość lokalna urządzenia" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "identidade efémera" + "value" : "identidade local do dispositivo" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "identidade efêmera" + "value" : "identidade local do dispositivo" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "эфемерная личность" + "value" : "идентичность на устройстве" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "tillfällig identitet" + "value" : "enhetslokal identitet" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "தற்காலிக அடையாளம்" + "value" : "சாதன-உள்ளூர் அடையாளம்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ตัวตนชั่วคราว" + "value" : "ตัวตนในเครื่อง" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geçici kimlik" + "value" : "cihaza yerel kimlik" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "ефемерна ідентичність" + "value" : "ідентичність на пристрої" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "عارضی شناخت" + "value" : "ڈیوائس مقامی شناخت" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "danh tính tạm thời" + "value" : "danh tính cục bộ trên thiết bị" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "临时身份" + "value" : "设备本地身份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "臨時身份" + "value" : "裝置本機身分" } } } @@ -11745,181 +11745,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "لا خوادم أو حسابات أو جمع بيانات" + "value" : "لا حسابات، لا أرقام هواتف، لا جمع بيانات" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "কোনো সার্ভার, অ্যাকাউন্ট বা ডেটা সংগ্রহ নেই" + "value" : "কোনো অ্যাকাউন্ট নেই, ফোন নম্বর নেই, ডেটা সংগ্রহ নেই" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "keine server, konten oder datensammlung" + "value" : "keine konten, keine telefonnummern, keine datensammlung" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "no servers, accounts, or data collection" + "value" : "no accounts, no phone numbers, no data collection" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "sin servidores, cuentas ni recopilación de datos" + "value" : "sin cuentas, sin números de teléfono, sin recopilación de datos" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "بدون سرور، حساب کاربری یا جمع‌آوری داده" + "value" : "بدون حساب، بدون شماره تلفن، بدون جمع‌آوری داده" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "walang mga server, account, o pagkuha ng data" + "value" : "walang account, walang numero ng telepono, walang pangongolekta ng data" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "sans serveurs, comptes ni collecte de données" + "value" : "pas de comptes, pas de numéros de téléphone, pas de collecte de données" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "אין שרתים, חשבונות או איסוף נתונים" + "value" : "בלי חשבונות, בלי מספרי טלפון, בלי איסוף נתונים" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "कोई सर्वर, खाते या डेटा संग्रह नहीं" + "value" : "कोई खाता नहीं, कोई फ़ोन नंबर नहीं, कोई डेटा संग्रह नहीं" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "tanpa server, akun, atau pengumpulan data" + "value" : "tanpa akun, tanpa nomor telepon, tanpa pengumpulan data" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "niente server, account o raccolta dati" + "value" : "niente account, niente numeri di telefono, nessuna raccolta dati" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "サーバーもアカウントもデータ収集もなし" + "value" : "アカウントなし・電話番号なし・データ収集なし" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버와 계정이 없으며 데이터를 수집하지 않습니다" + "value" : "계정 없음, 전화번호 없음, 데이터 수집 없음" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "tanpa server, akun, atau pengumpulan data" + "value" : "tiada akaun, tiada nombor telefon, tiada pengumpulan data" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "सर्भर, खाताहरू वा तथ्याङ्क सङ्कलन छैन" + "value" : "कुनै खाता छैन, फोन नम्बर छैन, डेटा सङ्कलन छैन" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "geen servers, accounts of dataverzameling" + "value" : "geen accounts, geen telefoonnummers, geen dataverzameling" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "bez serwerów, kont ani zbierania danych" + "value" : "bez kont, bez numerów telefonów, bez zbierania danych" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "sem servidores, contas ou recolha de dados" + "value" : "sem contas, sem números de telefone, sem recolha de dados" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "sem servidores, contas ou coleta de dados" + "value" : "sem contas, sem números de telefone, sem coleta de dados" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "без серверов, аккаунтов и сбора данных" + "value" : "без аккаунтов, без номеров телефона, без сбора данных" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "inga servrar, konton eller datainsamling" + "value" : "inga konton, inga telefonnummer, ingen datainsamling" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "சர்வர்கள் இல்லை, கணக்குகள் இல்லை, தரவுச் சேகரிப்பு இல்லை" + "value" : "கணக்குகள் இல்லை, தொலைபேசி எண்கள் இல்லை, தரவு சேகரிப்பு இல்லை" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ไม่มีเซิร์ฟเวอร์ บัญชี หรือการเก็บข้อมูล" + "value" : "ไม่มีบัญชี ไม่มีเบอร์โทรศัพท์ ไม่มีการเก็บข้อมูล" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "sunucu, hesap veya veri toplama yok" + "value" : "hesap yok, telefon numarası yok, veri toplama yok" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "жодних серверів, обліковок чи збору даних" + "value" : "без облікових записів, без номерів телефону, без збору даних" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "کوئی سرور، اکاؤنٹس یا ڈیٹا جمع نہیں کیا جاتا" + "value" : "نہ اکاؤنٹ، نہ فون نمبر، نہ ڈیٹا اکٹھا کرنا" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "không máy chủ, không tài khoản, không thu thập dữ liệu" + "value" : "không tài khoản, không số điện thoại, không thu thập dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无服务器、无账号、无数据收集" + "value" : "无账号、无电话号码、无数据收集" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無伺服器、無帳號、無數據收集" + "value" : "無帳號、無電話號碼、無資料收集" } } } @@ -52662,181 +52662,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تحدث مع أشخاص قريبين عبر قنوات geohash. نشارك geohash تقريبي فقط، وليس gps الدقيق. يتم إخفاء عنوان ip لأن كل المرور يمر عبر tor." + "value" : "دردش مع من حولك عبر قنوات geohash. الـ geohash المحدد علني وقد يكشف منطقة تقريبية؛ لا تتم مشاركة إحداثيات GPS الدقيقة أبدًا. مع تشغيل tor (الوضع الافتراضي)، ترى المرحّلات عنوان tor بدلاً من عنوان IP الخاص بك." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "জিওহ্যাশ চ্যানেল দিয়ে কাছাকাছি অঞ্চলের মানুষের সঙ্গে চ্যাট করুন। শুধুই স্থূল জিওহ্যাশ শেয়ার হয়, কখনো সঠিক GPS নয়। আপনার IP টর দিয়ে সব ট্রাফিক রাউট করে লুকানো থাকে।" + "value" : "geohash চ্যানেল ব্যবহার করে কাছের মানুষদের সঙ্গে চ্যাট করুন। নির্বাচিত geohash সর্বজনীন এবং আনুমানিক এলাকা প্রকাশ করতে পারে; সঠিক GPS কখনও শেয়ার হয় না। tor চালু থাকলে (ডিফল্ট), রিলেগুলো আপনার IP-এর বদলে tor-এর ঠিকানা দেখে।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "chatte mit menschen in deiner nähe über geohash-kanäle. geteilt wird nur ein grober geohash, niemals exakte gps-daten. deine ip bleibt verborgen, weil der gesamte verkehr über tor läuft." + "value" : "chatte mit leuten in deiner nähe über geohash-kanäle. der gewählte geohash ist öffentlich und kann ein ungefähres gebiet verraten; exaktes GPS wird nie geteilt. mit eingeschaltetem tor (standard) sehen relays tors adresse statt deiner IP-adresse." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor." + "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. with tor on (the default), relays see tor's address instead of your IP address." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "chatea con personas cercanas usando canales geohash. Solo se comparte un geohash aproximado, nunca GPS exacto. Tu IP se oculta al enrutar todo el tráfico por Tor." + "value" : "chatea con gente cercana usando canales geohash. el geohash seleccionado es público y puede revelar una zona aproximada; el GPS exacto nunca se comparte. con tor activado (el valor predeterminado), los relés ven la dirección de tor en lugar de tu IP." } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "با کانال‌های ژئوهش با افراد نزدیک خود گفتگو کنید. ژئوهش انتخاب‌شده عمومی است و ممکن است منطقهٔ تقریبی شما را نشان دهد؛ GPS دقیق هرگز به اشتراک گذاشته نمی‌شود. با عبور همهٔ ترافیک از Tor، آدرس IP شما پنهان می‌ماند." + "value" : "با افراد نزدیک خود از طریق کانال‌های geohash گفتگو کنید. geohash انتخابی عمومی است و ممکن است منطقه‌ای تقریبی را نشان دهد؛ GPS دقیق هرگز به اشتراک گذاشته نمی‌شود. با روشن بودن tor (پیش‌فرض)، رله‌ها به جای IP شما آدرس tor را می‌بینند." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. coarse na geohash lang ang ibinabahagi, hindi ang eksaktong GPS. tinatago ang iyong IP address sa pagreruta ng lahat ng traffic sa tor." + "value" : "makipag-chat sa mga taong malapit sa iyo gamit ang mga geohash channel. pampubliko ang napiling geohash at maaaring magbunyag ng tinatayang lugar; hindi kailanman ibinabahagi ang eksaktong GPS. kapag naka-on ang tor (ang default), ang address ng tor ang nakikita ng mga relay sa halip na ang iyong IP." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "discute avec les personnes proches grâce aux canaux geohash. seul un geohash grossier est partagé, jamais de gps exact. ton ip reste cachée car tout le trafic passe par tor." + "value" : "discutez avec les gens autour de vous via les canaux geohash. le geohash choisi est public et peut révéler une zone approximative ; le GPS exact n'est jamais partagé. avec tor activé (par défaut), les relais voient l'adresse de tor au lieu de votre IP." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "שוחח עם אנשים קרובים בערוצי geohash. משתף רק geohash גס, אף פעם לא gps מדויק. כתובת ה-ip מוסתרת כי כל התעבורה עוברת דרך tor." + "value" : "שוחחו עם אנשים בסביבה דרך ערוצי geohash. ה-geohash שנבחר הוא ציבורי ועשוי לחשוף אזור משוער; מיקום GPS מדויק לעולם לא משותף. כש-tor פועל (ברירת המחדל), הממסרים רואים את הכתובת של tor במקום ה-IP שלכם." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "जियोहैश चैनलों से आसपास के लोगों से चैट करें। केवल मोटा जियोहैश साझा होता है, कभी सटीक GPS नहीं। आपका IP सारा ट्रैफ़िक Tor से रूट कर छिपा रहता है।" + "value" : "geohash चैनलों के ज़रिए आस-पास के लोगों से चैट करें। चुना गया geohash सार्वजनिक है और अनुमानित क्षेत्र दिखा सकता है; सटीक GPS कभी साझा नहीं होता। tor चालू होने पर (डिफ़ॉल्ट), रिले आपके IP की जगह tor का पता देखते हैं।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." + "value" : "mengobrol dengan orang di sekitar melalui kanal geohash. geohash yang dipilih bersifat publik dan dapat mengungkap area perkiraan; GPS persis tidak pernah dibagikan. dengan tor aktif (bawaan), relay melihat alamat tor alih-alih IP anda." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "chatta con le persone vicine tramite canali geohash. condividiamo solo geohash approssimativi, mai il gps esatto. il tuo ip resta nascosto perché tutto il traffico passa da tor." + "value" : "chatta con chi ti sta vicino usando i canali geohash. il geohash selezionato è pubblico e può rivelare un'area approssimativa; il GPS esatto non viene mai condiviso. con tor attivo (impostazione predefinita), i relay vedono l'indirizzo di tor invece del tuo IP." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "geohashチャンネルで近くの人と会話。共有されるのはざっくりしたgeohashだけで正確なgpsは含みません。全トラフィックをtor経由にすることであなたのipを隠します。" + "value" : "geohash チャンネルで近くの人とチャットできます。選択した geohash は公開され、おおよそのエリアが分かる場合があります。正確な GPS は決して共有されません。tor が有効(デフォルト)なら、リレーにはあなたの IP ではなく tor のアドレスが見えます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "geohash 채널을 사용해 주변 사람들과 대화하세요. 정확한 GPS가 아닌 대략적인 geohash만 공유됩니다. 모든 트래픽을 tor로 라우팅하여 IP 주소를 숨깁니다." + "value" : "geohash 채널로 주변 사람들과 대화하세요. 선택한 geohash는 공개되며 대략적인 지역이 드러날 수 있습니다. 정확한 GPS는 절대 공유되지 않습니다. tor가 켜져 있으면(기본값) 릴레이에는 내 IP 대신 tor의 주소가 보입니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "ngobrol dengan orang terdekat lewat kanal geohash. hanya geohash kasar yang dibagikan, tidak pernah gps tepat. alamat ip-mu tersembunyi karena seluruh trafik lewat tor." + "value" : "berbual dengan orang berdekatan melalui saluran geohash. geohash yang dipilih adalah umum dan mungkin mendedahkan kawasan anggaran; GPS tepat tidak pernah dikongsi. dengan tor dihidupkan (lalai), relay melihat alamat tor dan bukan IP anda." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "geohash च्यानलबाट नजिकका मानिससँग कुरा गर। केवल मोटामो geohash साझा हुन्छ, कहिल्यै सहि gps होइन। सबै ट्राफिक tor मार्फत गएका कारण तिम्रो ip लुकेको हुन्छ।" + "value" : "geohash च्यानलहरू प्रयोग गरेर नजिकका मानिसहरूसँग कुराकानी गर्नुहोस्। चयन गरिएको geohash सार्वजनिक हुन्छ र अनुमानित क्षेत्र देखाउन सक्छ; सटीक GPS कहिल्यै साझा हुँदैन। tor खुला हुँदा (पूर्वनिर्धारित), रिलेहरूले तपाईंको IP को सट्टा tor को ठेगाना देख्छन्।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "chat met mensen bij jou in de buurt via geohash-kanalen. enkel een grove geohash wordt gedeeld, nooit exacte GPS. je IP-adres wordt verborgen doordat al het verkeer via tor wordt gerouteerd." + "value" : "chat met mensen in de buurt via geohash-kanalen. de gekozen geohash is openbaar en kan een globaal gebied onthullen; exacte GPS wordt nooit gedeeld. met tor aan (standaard) zien relays het adres van tor in plaats van je IP-adres." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "rozmawiaj z osobami w pobliżu za pomocą kanałów geohash. udostępniany jest tylko zgrubny geohash, nigdy dokładny GPS. twój adres IP jest ukryty przez kierowanie całego ruchu przez tor." + "value" : "rozmawiaj z ludźmi w pobliżu przez kanały geohash. wybrany geohash jest publiczny i może zdradzić przybliżony obszar; dokładny GPS nigdy nie jest udostępniany. przy włączonym tor (domyślnie) przekaźniki widzą adres tora zamiast twojego IP." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "conversa com pessoas perto de ti usando canais geohash. apenas um geohash de baixa precisão é partilhado, nunca GPS exato. o teu IP fica oculto ao encaminhar todo o tráfego através do Tor." + "value" : "conversa com pessoas por perto através de canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é partilhado. com o tor ligado (predefinição), os relays veem o endereço do tor em vez do teu IP." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "converse com pessoas próximas usando canais geohash. apenas um geohash grosseiro é compartilhado, nunca gps exato. seu ip fica oculto ao rotear todo o tráfego por tor." + "value" : "converse com pessoas por perto usando canais geohash. o geohash selecionado é público e pode revelar uma área aproximada; o GPS exato nunca é compartilhado. com o tor ligado (padrão), os relays veem o endereço do tor em vez do seu IP." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "общайся с людьми рядом через каналы geohash. делится только грубый geohash, без точного gps. твой ip скрывается за счёт маршрутизации трафика через tor." + "value" : "общайтесь с людьми поблизости через geohash-каналы. выбранный geohash публичен и может раскрыть примерный район; точные GPS-координаты никогда не передаются. при включённом tor (по умолчанию) релеи видят адрес tor вместо вашего IP." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "chatta med folk nära dig via geohash-kanaler. endast en grov geohash delas, aldrig exakt GPS. din IP-adress döljs genom att all trafik går via tor." + "value" : "chatta med folk i närheten via geohash-kanaler. vald geohash är offentlig och kan avslöja ett ungefärligt område; exakt GPS delas aldrig. med tor på (standard) ser reläer tors adress istället för din IP." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் உரையாடுங்கள். மிகவும் பொது geohash மட்டுமே பகிரப்படும், துல்லியமான GPS அல்ல. உங்கள் IP, அனைத்து போக்குவரத்தையும் tor வழியாக மாற்றுவதன் மூலம் மறைக்கப்படுகிறது." + "value" : "geohash சேனல்கள் மூலம் அருகிலுள்ளவர்களுடன் அரட்டையடிக்கவும். தேர்ந்தெடுத்த geohash பொதுவானது, தோராயமான பகுதியை வெளிப்படுத்தலாம்; துல்லியமான GPS ஒருபோதும் பகிரப்படாது. tor இயக்கத்தில் இருந்தால் (இயல்புநிலை), ரிலேக்கள் உங்கள் IP க்குப் பதிலாக tor இன் முகவரியைக் காண்கின்றன." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แชทกับคนใกล้คุณผ่านช่อง geohash แบ่งปันเพียง geohash แบบหยาบ ไม่ใช่ GPS ที่แม่นยำ ที่อยู่ IP ของคุณถูกซ่อนด้วยการส่งข้อมูลผ่าน tor" + "value" : "แชทกับผู้คนใกล้ตัวผ่านช่อง geohash โดย geohash ที่เลือกเป็นแบบสาธารณะและอาจเผยพื้นที่โดยประมาณ พิกัด GPS ที่แน่นอนจะไม่ถูกแชร์ เมื่อเปิด tor (ค่าเริ่มต้น) รีเลย์จะเห็นที่อยู่ของ tor แทน IP ของคุณ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. yalnızca kaba bir geohash paylaşılır, tam GPS asla değil. IP adresiniz tüm trafik Tor üzerinden yönlendirilerek gizlenir." + "value" : "geohash kanallarıyla yakınınızdaki insanlarla sohbet edin. seçilen geohash herkese açıktır ve yaklaşık bir bölgeyi açığa çıkarabilir; tam GPS asla paylaşılmaz. tor açıkken (varsayılan) röleler IP'niz yerine tor'un adresini görür." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "спілкуйся з людьми поруч у каналах geohash. передається лише грубий geohash, без точного gps. твій ip приховується, бо весь трафік йде через tor." + "value" : "спілкуйтеся з людьми поруч через geohash-канали. вибраний geohash є публічним і може розкрити приблизний район; точні GPS-координати ніколи не передаються. з увімкненим tor (типово) релеї бачать адресу tor замість вашого IP." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "قریب کے لوگوں سے گفتگو کیلئے geohash چینلز استعمال کریں۔ صرف عمومی geohash شیئر کیا جاتا ہے، کبھی درست GPS نہیں۔ آپ کا IP tor کے ذریعے ساری ٹریفک روٹ کر کے چھپایا جاتا ہے۔" + "value" : "geohash چینلز کے ذریعے قریبی لوگوں سے چیٹ کریں۔ منتخب geohash عوامی ہے اور تخمینی علاقہ ظاہر کر سکتا ہے؛ درست GPS کبھی شیئر نہیں ہوتا۔ tor آن ہونے پر (ڈیفالٹ)، ریلے آپ کے IP کی بجائے tor کا پتہ دیکھتے ہیں۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "trò chuyện với người gần bạn bằng các kênh geohash. chỉ chia sẻ geohash thô, không bao giờ là GPS chính xác. địa chỉ IP của bạn được ẩn bằng cách định tuyến toàn bộ lưu lượng qua tor." + "value" : "trò chuyện với những người gần bạn qua các kênh geohash. geohash đã chọn là công khai và có thể tiết lộ khu vực gần đúng; GPS chính xác không bao giờ được chia sẻ. khi bật tor (mặc định), các relay thấy địa chỉ của tor thay vì IP của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用 geohash 频道与附近的人聊天。只会共享粗略 geohash,从不泄露精确 GPS。所有流量通过 tor 路由来隐藏你的 IP。" + "value" : "通过 geohash 频道与附近的人聊天。所选 geohash 是公开的,可能暴露大致区域;精确 GPS 绝不会被共享。开启 tor 时(默认),中继看到的是 tor 的地址而不是你的 IP。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用 geohash 頻道與附近的人聊天。只會共享粗略 geohash,從不洩露精確 GPS。所有流量透過 tor 路由來隱藏你的 IP。" + "value" : "透過 geohash 頻道與附近的人聊天。所選 geohash 是公開的,可能透露大致區域;精確 GPS 絕不會被分享。開啟 tor 時(預設),中繼看到的是 tor 的位址而不是你的 IP。" } } } diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index 61c0a5f8..afa7c53d 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -33,13 +33,22 @@ struct BitchatPeer: Equatable { return .bluetoothConnected } else if isReachable { return .meshReachable - } else if favoriteStatus?.isMutual == true { - // Mutual favorites can communicate via Nostr when offline + } else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil { + // Mutual favorites can communicate via Nostr when offline — but + // only with a stored recipient key. NostrTransport applies the + // same rule when computing reachable peers; without a key, + // "available" would be a lie, and the DM header's + // "end-to-end encrypted" caption keys off this state. return .nostrAvailable } else { return .offline } } + + /// The Nostr key a private envelope would actually be sealed to, if any. + var reachableNostrPublicKey: String? { + nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey + } var isFavorite: Bool { favoriteStatus?.isFavorite ?? false diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index a2bd6297..e2d0fae8 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -440,7 +440,13 @@ final class ChatPeerIdentityCoordinator { return cachedStatus } - let hasEverEstablishedSession = getFingerprint(for: peerID) != nil + // The status must reflect the LIVE session, never history. The old + // mapping returned secured/verified for any peer whose fingerprint was + // ever persisted — so after a cold launch or a handshake FAILURE the + // DM header still showed a solid lock and the composer still claimed + // "end-to-end encrypted" with no secure session in existence. A + // remembered fingerprint changes what an established session upgrades + // to (verified vs secured); it must not conjure a lock on its own. let sessionState = context.noiseSessionState(for: peerID) let status: EncryptionStatus @@ -448,11 +454,11 @@ final class ChatPeerIdentityCoordinator { case .established: status = verifiedEncryptionStatus(for: peerID) case .handshaking, .handshakeQueued: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking + status = .noiseHandshaking case .none: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake + status = .noHandshake case .failed: - status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none + status = .none } context.setCachedEncryptionStatus(status, for: peerID) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..4b5bfae1 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -615,7 +615,9 @@ private struct ContentPrivateChatSheetView: View { // Geohash DMs use BitChat's private-envelope encryption over Nostr — // always end-to-end encrypted, // even though they carry no Noise session status. Mesh DMs earn the - // "encrypted" claim only once the Noise handshake has secured. + // "encrypted" claim only once the Noise handshake has secured — or + // when the peer is reachable only over Nostr, where delivery is + // gift-wrapped end-to-end without a Noise session. let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let noiseSecured: Bool = { switch privateConversationModel.selectedHeaderState?.encryptionStatus { @@ -623,7 +625,8 @@ private struct ContentPrivateChatSheetView: View { default: return false } }() - if isGeoDM || noiseSecured { + let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable + if isGeoDM || noiseSecured || nostrTransport { return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted") } return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established") diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 788d66c4..dcc2bc84 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -27,6 +27,9 @@ struct LocationChannelsSheet: View { static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find" static let teleport: LocalizedStringKey = "location_channels.action.teleport" static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title" + // Same string the settings pane shows under the tor toggle — the + // warning belongs wherever the exposure is about to happen. + static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address") static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region") static func quickJoinDescription(_ regionName: String) -> String { @@ -126,6 +129,16 @@ struct LocationChannelsSheet: View { .bitchatFont(size: 12) .foregroundColor(palette.secondary) + // The description's tor claim is only true while tor is on; + // when it's off, say what that exposes right where the person + // is about to join a channel, not just in settings. + if !locationChannelsModel.userTorEnabled { + Text(verbatim: Strings.torOffWarning) + .bitchatFont(size: 11) + .foregroundColor(palette.alertRed) + .fixedSize(horizontal: false, vertical: true) + } + Group { switch locationChannelsModel.permissionState { case .notDetermined: diff --git a/bitchatTests/BitchatPeerTests.swift b/bitchatTests/BitchatPeerTests.swift index eba4e4c7..101f30f8 100644 --- a/bitchatTests/BitchatPeerTests.swift +++ b/bitchatTests/BitchatPeerTests.swift @@ -36,6 +36,30 @@ struct BitchatPeerTests { #expect(peer.statusIcon == "🌙") } + @Test("Mutual favorite without a stored Nostr key is offline, not nostr-available") + func mutualFavoriteWithoutNostrKeyIsOffline() { + let peerID = PeerID(str: "0123456789abcdef") + let noiseKey = Data((0..<32).map(UInt8.init)) + var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false) + peer.favoriteStatus = FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nil, + peerNickname: "A", + isFavorite: true, + theyFavoritedUs: true, + favoritedAt: Date(timeIntervalSince1970: 1), + lastUpdated: Date(timeIntervalSince1970: 2) + ) + + // Nothing to seal a Nostr envelope to: claiming availability here + // would relight the DM header's "end-to-end encrypted" caption with + // neither a Noise session nor a usable recipient key. + #expect(peer.connectionState == .offline) + + peer.nostrPublicKey = "abcdef" + #expect(peer.connectionState == .nostrAvailable) + } + @Test("Mutual offline peers show Nostr icon") func mutualFavoriteOfflinePeerShowsNostrIcon() { let peerID = PeerID(str: "0011223344556677") diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index 982a6de3..0bfcd306 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -360,6 +360,37 @@ struct ChatPeerIdentityCoordinatorContextTests { #expect(context.cachedEncryptionStatuses[peerID] == nil) } + @Test @MainActor + func getEncryptionStatus_reflectsLiveSessionNotHistory() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "8877665544332211") + + // A remembered (even verified) fingerprint must not conjure a lock on + // its own: the DM header and "end-to-end encrypted" caption follow + // this status, and claiming secured with no live session is the false + // security signal this mapping used to produce. + context.fingerprintsByPeerID[peerID] = "fp" + context.verifiedFingerprintSet = ["fp"] + + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .handshaking + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking) + + // A FAILED handshake is a red lock.slash, not yesterday's seal. + struct HandshakeError: Error {} + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .failed(HandshakeError()) + #expect(coordinator.getEncryptionStatus(for: peerID) == .none) + + // Only a live established session upgrades to the verified seal. + coordinator.invalidateEncryptionCache(for: peerID) + context.noiseSessionStates[peerID] = .established + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + } + @Test @MainActor func resolveNickname_walksMeshIdentityAndAnonFallbacks() async { let context = MockChatPeerIdentityContext() From 4f30433406f6cfb062a449949be7956c98d81250 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:57:25 +0200 Subject: [PATCH 07/14] Remove the public-channel screenshot broadcast (#1596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove the public-channel screenshot broadcast Taking a screenshot used to announce "* nickname took a screenshot *" to the entire active public channel — a mesh broadcast, or on geohash channels a mined ephemeral event published to the 5 nearest Nostr relays, permanently timestamping that this nickname was present and active at that place. Documenting something (or someone) is a core use of a protest app; it must not out the person doing it. - Public channels (mesh and geohash) no longer send anything on screenshot. The mined-event helper is deleted and the lifecycle context trimmed of its now-unused requirements. - A geohash timeline screenshot instead raises the existing local location-privacy warning alert (same one the channel sheet uses) — warn the person, tell no one. - DM screenshot notices remain, and the local "you took a screenshot" echo is now honest: it only appears when the notice actually went to the peer (it used to render even when no established session existed and nothing was sent). Co-Authored-By: Claude Fable 5 * Review fixes: pin the screenshot routing table The geohash warn path lived only in AppRuntime with no coverage. The decision is now a pure nonisolated table (AppRuntime.resolveScreenshotResponse) pinned by tests: location sheet or geohash timeline → local privacy alert (nothing sent), App Info → nothing, mesh/DM → chat layer (where public channels stay silent). Mesh deliberately gets no local alert: a mesh screenshot reveals no place and triggers no send, so there is nothing actionable to warn about — alerting on every screenshot would train people to dismiss the one alert that matters. Rationale now documented on the response enum. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppRuntime.swift | 61 +++++++++-- .../ViewModels/ChatLifecycleCoordinator.swift | 86 ++++----------- ...ChatLifecycleCoordinatorContextTests.swift | 101 ++++++++++++------ bitchatTests/ChatViewModelTests.swift | 6 +- 4 files changed, 149 insertions(+), 105 deletions(-) diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 0158ea0a..3d5bc5bc 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -424,16 +424,24 @@ private extension AppRuntime { } func handleScreenshotCaptured() { - if appChromeModel.isLocationChannelsSheetPresented { + let isLocationChannelActive: Bool = { + if case .location = chatViewModel.activeChannel { return true } + return false + }() + + switch Self.resolveScreenshotResponse( + isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented, + isAppInfoPresented: appChromeModel.isAppInfoPresented, + hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil, + isLocationChannelActive: isLocationChannelActive + ) { + case .warnLocally: appChromeModel.triggerScreenshotPrivacyWarning() - return + case .ignore: + break + case .forwardToChat: + chatViewModel.handleScreenshotCaptured() } - - if appChromeModel.isAppInfoPresented { - return - } - - chatViewModel.handleScreenshotCaptured() } func openExternalURL(_ url: URL) { @@ -450,3 +458,40 @@ private extension AppRuntime { } } } + +// MARK: - Screenshot routing + +extension AppRuntime { + /// What a screenshot triggers. Nothing on this table sends anything to + /// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`). + enum ScreenshotCaptureResponse: Equatable { + /// Show the local location-privacy alert; nothing is sent anywhere. + case warnLocally + /// Do nothing. App Info holds no conversation or location content. + case ignore + /// Hand to the chat layer: a DM notice goes to the peer when a + /// secure session exists; public timelines stay silent. Mesh + /// deliberately gets no local alert either — a mesh screenshot + /// reveals no place and triggers no send, so there is nothing to + /// warn about, and alerting on every screenshot would train people + /// to dismiss the one alert that matters (the location one). + case forwardToChat + } + + /// Pure decision table so the screenshot routing is testable without a + /// runtime. + nonisolated static func resolveScreenshotResponse( + isLocationChannelsSheetPresented: Bool, + isAppInfoPresented: Bool, + hasPrivateChatOpen: Bool, + isLocationChannelActive: Bool + ) -> ScreenshotCaptureResponse { + if isLocationChannelsSheetPresented { return .warnLocally } + if isAppInfoPresented { return .ignore } + // A geohash timeline screenshot still reveals a place — warn the + // person taking it, locally, with the same alert the channel sheet + // uses. + if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally } + return .forwardToChat + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index a2d8e8ea..d6684e8e 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -40,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject { /// Schedules main-actor work after a UI-timing delay. Injected so tests /// can run the work synchronously instead of polling wall-clock queues. func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) - func addSystemMessage(_ content: String) // MARK: Peers & sessions func peerNickname(for peerID: PeerID) -> String? @@ -55,13 +54,10 @@ protocol ChatLifecycleContext: AnyObject { func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) @discardableResult func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) // MARK: Nostr & geohash - var isTeleported: Bool { get } func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func recordGeoParticipant(pubkeyHex: String) // MARK: Favorites (shared with `ChatPrivateConversationContext`) /// The persisted favorite relationship for the peer's Noise static key, if any. @@ -80,10 +76,10 @@ extension ChatViewModel: ChatLifecycleContext { // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, // `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`, - // `markPrivateChatRead(_:)`, `addSystemMessage(_:)`, + // `markPrivateChatRead(_:)`, // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, - // the routing/ack members, `isTeleported`, - // `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`, + // the routing/ack members, + // `deriveNostrIdentity(forGeohash:)`, // and `favoriteRelationship(forNoiseKey:)` // are shared requirements with the other contexts or satisfied by // existing `ChatViewModel` members. The members below flatten nested @@ -143,34 +139,21 @@ final class ChatLifecycleCoordinator { } func handleScreenshotCaptured() { + // Public channels never announce screenshots. The old broadcast told + // everyone in radio range — and, on geohash channels, public Nostr + // relays, permanently — that this nickname was present and active + // here right now. Documenting something (or someone) is a core use + // of a protest app; it must not out the person doing it. Screenshot + // notices remain a DM-only courtesy between the two people involved. + guard let peerID = context.selectedPrivateChatPeer else { return } + let screenshotMessage = "* \(context.nickname) took a screenshot *" - - if let peerID = context.selectedPrivateChatPeer { - sendPrivateScreenshotNotificationIfPossible( - screenshotMessage, - to: peerID - ) + // Only echo "you took a screenshot" when the peer was actually + // notified — the unconditional echo used to imply a notice that + // frequently was never sent (no established session). + if sendPrivateScreenshotNotificationIfPossible(screenshotMessage, to: peerID) { appendPrivateScreenshotNotice(for: peerID) - return } - - switch context.activeChannel { - case .mesh: - context.sendMeshMessage( - screenshotMessage, - mentions: [], - messageID: UUID().uuidString, - timestamp: Date() - ) - - case .location(let channel): - sendPublicGeohashScreenshotMessage( - screenshotMessage, - channel: channel - ) - } - - context.addSystemMessage("you took a screenshot") } func saveIdentityState() { @@ -293,8 +276,10 @@ final class ChatLifecycleCoordinator { } private extension ChatLifecycleCoordinator { - func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) { - guard let peerNickname = context.peerNickname(for: peerID) else { return } + /// Returns whether the notice actually went out, so the caller can keep + /// the local echo honest. + func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) -> Bool { + guard let peerNickname = context.peerNickname(for: peerID) else { return false } let sessionState = context.noiseSessionState(for: peerID) switch sessionState { @@ -305,12 +290,14 @@ private extension ChatLifecycleCoordinator { recipientNickname: peerNickname, messageID: UUID().uuidString ) + return true case .none, .failed, .handshakeQueued, .handshaking: SecureLogger.debug( "Skipping screenshot notification to \(peerID) - no established session", category: .security ) + return false } } @@ -329,37 +316,6 @@ private extension ChatLifecycleCoordinator { context.appendPrivateMessage(notice, to: peerID) } - func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { - Task { @MainActor [weak context = self.context] in - guard let context else { return } - - do { - let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) - let event = try await NostrProtocol.createMinedEphemeralGeohashEvent( - content: message, - geohash: channel.geohash, - senderIdentity: identity, - nickname: context.nickname, - teleported: context.isTeleported - ) - - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - } catch { - SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) - context.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - } - } - } - func deliveryStatusRank(_ status: DeliveryStatus) -> Int { switch status { case .notSentYet: return 0 diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 1ed083a0..d969cd15 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -7,12 +7,10 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the geohash-screenshot branch publishes via -// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered -// by the full view-model tests. The GeoDM read pass, the favorites-backed -// mesh/Nostr read-receipt branch (favorites are injected through the -// context), message merging, screenshot notices, and lifecycle persistence -// flows are covered here. +// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr +// read-receipt branch (favorites are injected through the context), message +// merging, DM screenshot notices, and lifecycle persistence flows are +// covered here. Screenshots are never announced to public channels. // import Testing @@ -42,7 +40,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { var nostrKeyMapping: [PeerID: String] = [:] private(set) var ownerLevelReadPasses: [PeerID] = [] private(set) var managerReadMarks: [PeerID] = [] - private(set) var systemMessages: [String] = [] // Conversation store intents @discardableResult @@ -79,8 +76,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { work() } - func addSystemMessage(_ content: String) { systemMessages.append(content) } - // Peers & sessions var nicknamesByPeerID: [PeerID: String] = [:] var peersByID: [PeerID: BitchatPeer] = [:] @@ -99,7 +94,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { // Routing & receipts private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = [] private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] - private(set) var meshBroadcasts: [String] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { @@ -112,20 +106,12 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { return routeReadReceiptResult } - func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { - meshBroadcasts.append(content) - } - func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoReadReceipts.append((messageID, recipientHex)) } // Nostr & geohash - var isTeleported = false - private(set) var recordedGeoParticipants: [String] = [] - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } - func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } // Favorites var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] @@ -264,40 +250,39 @@ struct ChatLifecycleCoordinatorContextTests { } @Test @MainActor - func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async { + func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) let peerID = PeerID(str: "1122334455667788") context.selectedPrivateChatPeer = peerID context.nicknamesByPeerID[peerID] = "alice" - // No established session: local notice only, no network send. + // No established session: nothing goes out, so nothing is echoed — + // a local "you took a screenshot" would imply the peer was told. coordinator.handleScreenshotCaptured() #expect(context.routedPrivateMessages.isEmpty) - #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) - #expect(context.privateChats[peerID]?.first?.sender == "system") + #expect(context.privateChats.isEmpty) - // Established session: the peer is notified too. + // Established session: the peer is notified and the echo appears. context.noiseSessionStates[peerID] = .established coordinator.handleScreenshotCaptured() #expect(context.routedPrivateMessages.count == 1) #expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *") #expect(context.routedPrivateMessages.first?.recipientNickname == "alice") - #expect(context.privateChats[peerID]?.count == 2) - // The public-channel system message is not used for private chats. - #expect(context.systemMessages.isEmpty) - #expect(context.meshBroadcasts.isEmpty) + #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) + #expect(context.privateChats[peerID]?.first?.sender == "system") } @Test @MainActor - func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async { + func handleScreenshotCaptured_publicChannel_staysSilent() async { let context = MockChatLifecycleContext() let coordinator = ChatLifecycleCoordinator(context: context) + // No DM selected: screenshots must never announce presence to a + // public channel (mesh broadcast or Nostr relays). coordinator.handleScreenshotCaptured() - #expect(context.meshBroadcasts == ["* me took a screenshot *"]) - #expect(context.systemMessages == ["you took a screenshot"]) + #expect(context.routedPrivateMessages.isEmpty) #expect(context.privateChats.isEmpty) } @@ -365,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests { } } + +// MARK: - Screenshot Routing + +/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path, +/// the App Info exemption, and the deliberate mesh silence. Combined with +/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves +/// location + screenshot → local privacy alert, and nothing sent anywhere. +struct ScreenshotCaptureRoutingTests { + + @Test("Location channels warn locally; the sheet warns everywhere") + func locationScreenshotsWarnLocally() { + // Geohash timeline, no DM open: warn the person, tell no one. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: false, + isLocationChannelActive: true + ) == .warnLocally) + + // The channel sheet reveals location regardless of active channel. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: true, + isAppInfoPresented: false, + hasPrivateChatOpen: true, + isLocationChannelActive: false + ) == .warnLocally) + } + + @Test("App Info is exempt; mesh and DMs forward to the chat layer") + func nonLocationScreenshotsForwardOrIgnore() { + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: true, + hasPrivateChatOpen: false, + isLocationChannelActive: true + ) == .ignore) + + // Mesh timeline: forwarded, where the coordinator stays silent for + // public channels — no local alert either (nothing is sent and no + // place is revealed; see ScreenshotCaptureResponse docs). + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: false, + isLocationChannelActive: false + ) == .forwardToChat) + + // An open DM keeps its peer notice even from a location channel. + #expect(AppRuntime.resolveScreenshotResponse( + isLocationChannelsSheetPresented: false, + isAppInfoPresented: false, + hasPrivateChatOpen: true, + isLocationChannelActive: true + ) == .forwardToChat) + } +} diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 7e4e46a1..0393b3ce 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests { } @Test @MainActor - func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async { + func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async { let (viewModel, transport) = makeTestableViewModel() let peerID = PeerID(str: "0000000000000002") transport.simulateConnect(peerID, nickname: "Alice") @@ -437,8 +437,10 @@ struct ChatViewModelServiceLifecycleTests { viewModel.selectedPrivateChatPeer = peerID viewModel.handleScreenshotCaptured() + // No session means no notice went out, so no local echo either — + // an echo here would imply Alice was told when she wasn't. #expect(transport.sentPrivateMessages.isEmpty) - #expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot") + #expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true) } } From c6cb4210e45b545c39948f4c24003c028891ca2e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:58:18 +0200 Subject: [PATCH 08/14] Add a recent-chats section so DM conversations can't become unreachable (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a recent-chats section so DM conversations can't become unreachable The largest IA hole from the design/UX audit: a direct conversation with someone who went offline (and isn't a mutual favorite) had no row anywhere in the UI. The roster filters to connected/reachable/mutual- favorite, the header envelope only exists while unread, and /msg can't resolve offline strangers — read a DM from a passerby, close the sheet, and the thread was still in memory with no way back to it. - PeerListModel now derives RecentChatRow entries from the ConversationStore's direct conversations: people absent from the rosters above, newest activity first, unread flag, deduplicated by fingerprint across ephemeral/stable peer-ID mirrors (newest wins), groups and blocked peers excluded. - A "chats" section (same header shape as #mesh/groups) renders them in the people sheet on both mesh and location channels — geoDMs from a channel since left stay reachable too. Tapping reopens the DM via the existing startConversation path; the section renders nothing when empty. - Rebuilds are driven by direct-conversation store changes only, so public-timeline traffic doesn't churn the sheet. 2 new strings, all 30 locales. New architecture test pins that an offline stranger's thread gets a row while group threads don't. Co-Authored-By: Claude Fable 5 * Review fixes: geo dedup and geoDM names in recent chats - A person still visible in the geohash roster could also get a recent chat row (the builder only subtracted mesh rows). Visible geohash pubkeys now join the identity set via their PeerID(nostr_:) form, so the absent-from-rosters contract holds on location channels too. - GeoDM rows went through resolveNickname, which only consults mesh identity sources and rendered nostr_… keys as an opaque anon fallback. They now resolve through geohashDisplayName (Nostr key mapping, bare-key fallback). Both pinned in the recent-chats architecture test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/PeerListModel.swift | 119 ++++++++ bitchat/Localizable.xcstrings | 372 ++++++++++++++++++++++++ bitchat/Views/ContentSheetViews.swift | 20 ++ bitchat/Views/RecentChatList.swift | 89 ++++++ bitchatTests/AppArchitectureTests.swift | 84 ++++++ 5 files changed, 684 insertions(+) create mode 100644 bitchat/Views/RecentChatList.swift diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index b365d09d..4f1fc633 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable { var id: String { peerID.id } } +/// A direct conversation whose person is NOT in any roster above it. Without +/// this section, a DM from a passerby became unreachable the moment they went +/// offline: the roster filters to connected/reachable/mutual-favorite, the +/// header envelope only exists while unread, and /msg can't resolve offline +/// strangers — the thread was still in memory with no row anywhere in the UI. +struct RecentChatRow: Identifiable, Equatable { + let peerID: PeerID + let displayName: String + let hasUnread: Bool + let lastActivity: Date + + var id: String { peerID.id } +} + @MainActor final class PeerListModel: ObservableObject { @Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = [] @Published private(set) var groupRows: [GroupChatRow] = [] + @Published private(set) var recentChatRows: [RecentChatRow] = [] @Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0 @@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject { } .store(in: &cancellables) + // Direct-conversation changes reorder or create recent-chat rows. + // Filtered to `.direct` so public-timeline traffic (every mesh or + // geohash message emits a change) doesn't rebuild the sheet. + conversations.changes + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + guard case .direct = Self.changedConversationID(change) else { return } + self?.refresh() + } + .store(in: &cancellables) + chatViewModel.groupStore.$groups .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject { let geohashPeople = buildGeohashPeople() let groupRows = buildGroupRows() + let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople) self.meshRows = meshRows reachableMeshPeerCount = meshCounts.reachable @@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject { self.geohashPeople = geohashPeople visibleGeohashPeerCount = geohashPeople.count self.groupRows = groupRows + self.recentChatRows = recentChatRows renderID = ( meshRows.map { "\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" @@ -255,10 +283,101 @@ final class PeerListModel: ObservableObject { } + groupRows.map { "group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)" + } + + recentChatRows.map { + "chat:\($0.id)-\($0.displayName)-\($0.hasUnread)" } ).joined(separator: "|") } + /// Direct conversations with people absent from the rosters above: + /// the offline passerby DM, the geoDM from a channel since left. Rows + /// mirrored under both an ephemeral and a stable peer ID collapse to + /// one row per identity (newest activity wins), matching how the + /// private-chat coordinators consolidate on open. + private func buildRecentChatRows( + meshRows: [MeshPeerRow], + geohashPeople: [GeohashPersonRow] + ) -> [RecentChatRow] { + // A conversation can be keyed by the stable Noise peer ID while the + // roster lists the ephemeral one — compare fingerprints too. + var visibleIdentities = Set() + for row in meshRows { + visibleIdentities.insert(row.peerID.id) + if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) { + visibleIdentities.insert(fingerprint) + } + } + // Someone visible in the geohash section must not also get a chat + // row: their GeoDM conversation is keyed by the nostr_ form of the + // same pubkey the roster lists. + for person in geohashPeople { + visibleIdentities.insert(PeerID(nostr_: person.id).id) + } + + struct Candidate { + let peerID: PeerID + let lastActivity: Date + } + var bestByIdentity: [String: Candidate] = [:] + + for (id, conversation) in conversations.conversationsByID { + guard case .direct(let handle) = id else { continue } + let peerID = handle.routingPeerID + // Groups have their own section; blocked people get no row. + guard !peerID.isGroup else { continue } + guard let lastMessage = conversation.messages.last else { continue } + guard !chatViewModel.isPeerBlocked(peerID) else { continue } + + let fingerprint = chatViewModel.getFingerprint(for: peerID) + if visibleIdentities.contains(peerID.id) { continue } + if let fingerprint, visibleIdentities.contains(fingerprint) { continue } + + let identityKey = fingerprint ?? peerID.id + if let existing = bestByIdentity[identityKey], + existing.lastActivity >= lastMessage.timestamp { + continue + } + bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp) + } + + return bestByIdentity.values + .sorted { $0.lastActivity > $1.lastActivity } + .map { candidate in + RecentChatRow( + peerID: candidate.peerID, + displayName: displayName(for: candidate.peerID), + hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID), + lastActivity: candidate.lastActivity + ) + } + } + + /// GeoDM conversations resolve through the Nostr mapping — + /// `resolveNickname` only consults mesh identity sources and would + /// render a `nostr_…` key as an opaque `anonnost` fallback. + private func displayName(for peerID: PeerID) -> String { + if peerID.isGeoDM { + return chatViewModel.geohashDisplayName(for: peerID) + } + return chatViewModel.resolveNickname(for: peerID) + } + + private static func changedConversationID(_ change: ConversationChange) -> ConversationID { + switch change { + case .appended(let id, _), + .updated(let id, _), + .statusChanged(let id, _, _), + .messageRemoved(let id, _), + .cleared(let id), + .removed(let id), + .unreadChanged(let id, _): + return id + case .migrated(_, let to): + return to + } + } + private func buildGroupRows() -> [GroupChatRow] { let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint() return chatViewModel.groupStore.groups.map { group in diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index b05370a7..9ca423a6 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2047,6 +2047,378 @@ } } }, + "chats.accessibility.open_hint" : { + "comment" : "Accessibility hint on a recent chat row explaining activation opens the direct conversation", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يفتح هذه المحادثة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই কথোপকথন খোলে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "öffnet diese unterhaltung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "opens this conversation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversación" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این گفتگو را باز می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "binubuksan ang usapang ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvre cette conversation" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פותח את השיחה הזו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह वार्तालाप खोलता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka percakapan ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "apre questa conversazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この会話を開きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 대화를 엽니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "membuka perbualan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो वार्तालाप खोल्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "opent dit gesprek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "otwiera tę rozmowę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "abre esta conversa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "открывает этот разговор" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "öppnar den här konversationen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த உரையாடலைத் திறக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เปิดการสนทนานี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu konuşmayı açar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "відкриває цю розмову" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ گفتگو کھولتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mở cuộc trò chuyện này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开此对话" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開啟此對話" + } + } + } + }, + "chats.section.header" : { + "comment" : "Section header above recent direct conversations in the people sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الدردشات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "চ্যাট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گفتگوها" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "discussions" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "צ'אטים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चैट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "obrolan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チャット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "채팅" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "च्याटहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "chats" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "czaty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "чаты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "chattar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அரட்டைகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "แชท" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sohbetler" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "чати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "چیٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trò chuyện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "聊天" + } + } + } + }, "content.system.media_delete_refused" : { "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", "extractionState" : "manual", diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 4b5bfae1..18c0f148 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -365,6 +365,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Direct conversations survive channel switches; the + // geoDM someone opened from another cell must stay + // reachable here too. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } else { PeopleSectionHeader( icon: "antenna.radiowaves.left.and.right", @@ -400,6 +410,16 @@ private struct ContentPeopleListView: View { showSidebar = true } ) + // Conversations with people no roster above lists + // anymore — without this, a read DM from an offline + // non-favorite had no row anywhere in the UI. + RecentChatList( + chats: peerListModel.recentChatRows, + onTapChat: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } } .padding(.top, 4) diff --git a/bitchat/Views/RecentChatList.swift b/bitchat/Views/RecentChatList.swift new file mode 100644 index 00000000..85180eb4 --- /dev/null +++ b/bitchat/Views/RecentChatList.swift @@ -0,0 +1,89 @@ +// +// RecentChatList.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import SwiftUI + +/// "chats" section for the people sheet: direct conversations with people +/// who are not in any roster above it (offline passersby, geoDMs from a +/// channel since left). Before this section existed, those threads were +/// unreachable the moment the unread envelope cleared — still in memory, +/// no row anywhere in the UI. Renders nothing when there are none. +struct RecentChatList: View { + @ThemedPalette private var palette + + let chats: [RecentChatRow] + let onTapChat: (PeerID) -> Void + + private enum Strings { + static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet") + static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages") + static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") + static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation") + } + + /// Relative "5 min ago" stamps; the formatter is locale-aware. + private static let relativeFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter + }() + + var body: some View { + if !chats.isEmpty { + VStack(alignment: .leading, spacing: 0) { + // Same glyph+label header shape as #mesh / groups. + PeopleSectionHeader( + icon: "bubble.left.and.bubble.right", + iconColor: palette.secondary, + title: Strings.header + ) + + ForEach(chats) { chat in + HStack(spacing: 4) { + Text(verbatim: chat.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + .lineLimit(1) + .truncationMode(.tail) + + Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + + Spacer() + + if chat.hasUnread { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.orange) + .help(Strings.newMessagesTooltip) + } + } + .padding(.horizontal) + .padding(.vertical, 6) + .contentShape(Rectangle()) + .onTapGesture { onTapChat(chat.peerID) } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityDescription(for: chat)) + .accessibilityAddTraits(.isButton) + .accessibilityHint(Strings.openChatHint) + } + } + } + } + + private func accessibilityDescription(for chat: RecentChatRow) -> String { + var parts: [String] = [ + chat.displayName, + Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()) + ] + if chat.hasUnread { parts.append(Strings.unread) } + return parts.joined(separator: ", ") + } +} diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 2fdb94df..360c89ab 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -458,6 +458,90 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("Recent chats list direct conversations with people absent from the rosters") + @MainActor + func peerListModelSurfacesRecentChatsForAbsentPeers() async { + let viewModel = makeArchitectureViewModel() + let offlinePeerID = PeerID(str: "00000000000000aa") + let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16)) + + // A DM thread from a passerby who has since gone offline: not in + // allPeers, not a favorite — previously unreachable once read. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "dm-1", + sender: "passerby", + content: "hello from the train", + timestamp: Date(timeIntervalSince1970: 100), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: offlinePeerID + ) + ], for: offlinePeerID) + // Group threads have their own section and must not duplicate here. + viewModel.seedPrivateChat([ + BitchatMessage( + id: "gm-1", + sender: "member", + content: "group hello", + timestamp: Date(timeIntervalSince1970: 200), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: groupPeerID + ) + ], for: groupPeerID) + + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversations: viewModel.conversations + ) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID } + } + + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) + #expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100)) + // The roster doesn't list this peer — that's exactly why the row exists. + #expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID }) + + // A geoDM thread resolves through the Nostr mapping (bare-key + // fallback here), never resolveNickname's mesh-only anon fallback. + let pubkeyHex = String(repeating: "ab", count: 32) + let geoDMPeer = PeerID(nostr_: pubkeyHex) + viewModel.seedPrivateChat([ + BitchatMessage( + id: "geo-1", + sender: "stranger", + content: "hello from another cell", + timestamp: Date(timeIntervalSince1970: 300), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: geoDMPeer + ) + ], for: geoDMPeer) + + await waitUntil { + peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + let geoRow = peerListModel.recentChatRows.first { $0.peerID == geoDMPeer } + #expect(geoRow?.displayName == geoDMPeer.bare) + + // While that person is visible in the geohash roster, the chat row + // collapses — same absent-from-rosters contract as mesh. + viewModel.participantTracker.setActiveGeohash("u4pruy") + viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") + + await waitUntil { + !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + } + #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) + #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) + } + @Test("PrivateConversationModel resolves canonical header state for the selected DM") @MainActor func privateConversationModelResolvesSelectedHeaderState() async { From 66fdea52aea90053700305885e3a3fd4fec8e4db Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:31:45 +0200 Subject: [PATCH 09/14] Make the panic wipe confirmable and its outcome visible (#1588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make the panic wipe confirmable and its outcome visible The triple-tap panic wipe fired instantly with no confirmation, on the same 18pt logo whose single tap opens App Info — while the far less destructive triple-tap-to-clear-chat 40pt below it asks first, and the Settings-pane panic button has always confirmed. A fumbled tap on the app's only help affordance could permanently destroy identity, keys, and messages. - Triple-tap now raises the same confirmation dialog the Settings pane uses (one extra tap under duress; zero accidental wipes). - A successful wipe leaves a "all data wiped — new identity created" system message: in duress, "did it work?" must not be a guess — the natural response to uncertainty is tapping again. - A wipe that does not commit (panicRecoveryBlocked) was previously invisible: the app looked wiped, sat silently offline, and keys could still be on disk. It now shows a persistent red banner under the header saying data may remain and a relaunch retries. - Settings/privacy copy no longer says "instantly" (all 30 locales). Co-Authored-By: Claude Fable 5 * Review fixes: dismiss sheets on wipe; host the dialog durably - Codex P1: the App Info danger-zone wipe left its sheet presented, so the failed-wipe banner (and the success message) rendered underneath an opaque modal. panicClearAllData now dismisses all chrome-owned presentation first; pinned by a new architecture test. - Review nit: the confirmation dialog moved from the logo Text to the ContentView header stack, so a covered/removed header can't take a pending dialog down with it. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 27 + bitchat/Localizable.xcstrings | 492 +++++++++++++++--- bitchat/ViewModels/ChatViewModel.swift | 7 + .../Components/PanicWipeBlockedBanner.swift | 42 ++ bitchat/Views/ContentHeaderView.swift | 11 +- bitchat/Views/ContentView.swift | 40 +- bitchatTests/AppArchitectureTests.swift | 48 ++ bitchatTests/ChatViewModelTests.swift | 4 +- 8 files changed, 601 insertions(+), 70 deletions(-) create mode 100644 bitchat/Views/Components/PanicWipeBlockedBanner.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 70a6c28c..8d369c5b 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -18,6 +18,12 @@ final class AppChromeModel: ObservableObject { @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @Published var showScreenshotPrivacyWarning = false + /// Triple-tapping the logo asks first; the dialog lives on the header. + @Published var showPanicConfirmation = false + /// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe + /// that did not commit must be visible, not just logged — the person who + /// triggered it needs to know data may remain on the device. + @Published private(set) var panicWipeBlocked = false private let chatViewModel: ChatViewModel private let onPanicWipe: () -> Void @@ -111,7 +117,24 @@ final class AppChromeModel: ObservableObject { prepareForPanic = preparation } + /// Entry point for the header triple-tap: confirm before destroying. + /// The Settings-pane button has always confirmed; the gesture now goes + /// through the same dialog so a mis-tap can't wipe the device. + func requestPanicWipe() { + showPanicConfirmation = true + } + func panicClearAllData() { + // A wipe invalidates everything on screen, and its outcome must be + // visible: the success message and the failed-wipe banner both live + // on the root timeline, so a sheet left up (the App Info danger-zone + // path keeps its sheet presented) would hide the one signal that says + // whether the wipe worked. + isAppInfoPresented = false + isLocationChannelsSheetPresented = false + isNoticesSheetPresented = false + showingFingerprintFor = nil + prepareForPanic?() onPanicWipe() chatViewModel.panicClearAllData() @@ -145,6 +168,10 @@ final class AppChromeModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$bluetoothState) + chatViewModel.$panicRecoveryBlocked + .receive(on: DispatchQueue.main) + .assign(to: &$panicWipeBlocked) + hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty } } diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 9ca423a6..7f393629 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2419,6 +2419,192 @@ } } }, + "content.banner.panic_blocked" : { + "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المسح غير مكتمل — قد تبقى بعض البيانات. أغلق bitchat وأعد فتحه لإعادة المحاولة." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মুছে ফেলা অসম্পূর্ণ — কিছু ডেটা থেকে যেতে পারে। আবার চেষ্টা করতে bitchat বন্ধ করে আবার খুলুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "löschen unvollständig — einige daten könnten übrig sein. bitchat beenden und erneut öffnen, um es noch einmal zu versuchen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe incomplete — some data may remain. quit and reopen bitchat to retry." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrado incompleto — pueden quedar algunos datos. cierra y vuelve a abrir bitchat para reintentar." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پاک‌سازی ناقص است — ممکن است برخی داده‌ها باقی مانده باشند. برای تلاش دوباره bitchat را ببندید و دوباره باز کنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi kumpleto ang pagbura — maaaring may natirang data. isara at buksan muli ang bitchat para subukan ulit." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacement incomplet — des données peuvent subsister. quittez et rouvrez bitchat pour réessayer." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המחיקה לא הושלמה — ייתכן שנשארו נתונים. סגרו ופתחו מחדש את bitchat כדי לנסות שוב." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिटाना अधूरा रहा — कुछ डेटा बच सकता है। फिर से कोशिश करने के लिए bitchat बंद करके दोबारा खोलें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penghapusan belum selesai — sebagian data mungkin tersisa. tutup dan buka kembali bitchat untuk mencoba lagi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellazione incompleta — alcuni dati potrebbero rimanere. chiudi e riapri bitchat per riprovare." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "消去が完了していません — データが残っている可能性があります。bitchat を終了して開き直すと再試行します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제가 완료되지 않았습니다 — 일부 데이터가 남아 있을 수 있습니다. bitchat을 종료했다가 다시 열면 재시도합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pemadaman tidak lengkap — sebahagian data mungkin masih ada. tutup dan buka semula bitchat untuk mencuba lagi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेटाउने काम अधूरो — केही डेटा बाँकी रहन सक्छ। पुनः प्रयास गर्न bitchat बन्द गरेर फेरि खोल्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wissen onvolledig — er kunnen gegevens achterblijven. sluit bitchat en open het opnieuw om het nogmaals te proberen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymazywanie niekompletne — część danych może pozostać. zamknij i otwórz bitchat ponownie, aby spróbować jeszcze raz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza incompleta — alguns dados podem permanecer. feche e reabra o bitchat para tentar novamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирание не завершено — часть данных могла остаться. закройте и снова откройте bitchat, чтобы повторить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "raderingen är ofullständig — viss data kan finnas kvar. avsluta och öppna bitchat igen för att försöka på nytt." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அழித்தல் முழுமையடையவில்லை — சில தரவு எஞ்சியிருக்கலாம். மீண்டும் முயற்சிக்க bitchat ஐ மூடி மீண்டும் திறக்கவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การล้างข้อมูลไม่สมบูรณ์ — อาจมีข้อมูลหลงเหลืออยู่ ปิดแล้วเปิด bitchat ใหม่เพื่อลองอีกครั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "silme tamamlanmadı — bazı veriler kalmış olabilir. yeniden denemek için bitchat'i kapatıp tekrar açın." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирання не завершено — частина даних могла залишитися. закрийте та знову відкрийте bitchat, щоб повторити." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مٹانا مکمل نہیں ہوا — کچھ ڈیٹا باقی رہ سکتا ہے۔ دوبارہ کوشش کے لیے bitchat بند کر کے دوبارہ کھولیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xoá chưa hoàn tất — một số dữ liệu có thể còn lại. thoát và mở lại bitchat để thử lại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除未完成 — 可能仍有数据残留。请退出并重新打开 bitchat 重试。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除未完成 — 可能仍有資料殘留。請結束並重新開啟 bitchat 重試。" + } + } + } + }, "content.system.media_delete_refused" : { "comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept", "extractionState" : "manual", @@ -12487,181 +12673,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "اضغط الشعار ثلاث مرات لمسح كل البيانات فوراً" + "value" : "انقر ثلاث مرات على الشعار لمسح جميع البيانات (مع تأكيد)" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "লোগোতে তিনবার ট্যাপ করলে সঙ্গে সঙ্গে সব ডেটা মুছে যাবে" + "value" : "সব ডেটা মুছতে লোগোতে তিনবার ট্যাপ করুন (নিশ্চিতকরণসহ)" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "tippe dreimal auf das logo, um alle daten sofort zu löschen" + "value" : "dreimal aufs logo tippen, um alle daten zu löschen (mit bestätigung)" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "triple-tap logo to instantly clear all data" + "value" : "triple-tap logo to wipe all data (asks to confirm)" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "toca el logotipo tres veces para borrar todos los datos al instante" + "value" : "toca tres veces el logo para borrar todos los datos (con confirmación)" } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "برای پاک کردن فوری همهٔ داده‌ها سه بار روی لوگو بزن" + "value" : "برای پاک کردن همه داده‌ها سه بار روی لوگو بزنید (با تأیید)" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "i-tap ang logo nang tatlong beses para agad burahin ang lahat ng data" + "value" : "i-tap nang tatlong beses ang logo para burahin ang lahat ng data (may kumpirmasyon)" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "tape trois fois sur le logo pour tout effacer instantanément" + "value" : "touchez trois fois le logo pour effacer toutes les données (avec confirmation)" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "הקש על הלוגו שלוש פעמים למחיקת כל הנתונים מייד" + "value" : "הקישו שלוש פעמים על הלוגו כדי למחוק את כל הנתונים (עם אישור)" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "लोगो पर तीन बार टैप करें और तुरंत सारा डेटा साफ़ करें" + "value" : "सारा डेटा मिटाने के लिए लोगो पर तीन बार टैप करें (पुष्टि के साथ)" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" + "value" : "ketuk logo tiga kali untuk menghapus semua data (dengan konfirmasi)" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "tocca il logo tre volte per cancellare subito tutti i dati" + "value" : "tocca tre volte il logo per cancellare tutti i dati (con conferma)" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ロゴを3回タップすると全データを即削除" + "value" : "ロゴを3回タップですべてのデータを消去(確認あり)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로고를 세 번 탭하여 모든 데이터 즉시 삭제할 수 있습니다" + "value" : "로고를 세 번 탭하면 모든 데이터 삭제 (확인 후 진행)" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "ketuk logo tiga kali untuk langsung menghapus semua data" + "value" : "ketik logo tiga kali untuk memadamkan semua data (dengan pengesahan)" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "लगो तीन पटक ट्याप गर्दा सबै डाटा तुरुन्त मेटिन्छ" + "value" : "सबै डेटा मेटाउन लोगोमा तीन पटक ट्याप गर्नुहोस् (पुष्टिसहित)" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "tik drie keer op het logo om alle data direct te wissen" + "value" : "tik drie keer op het logo om alle gegevens te wissen (met bevestiging)" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "stuknij logo trzy razy, aby natychmiast usunąć wszystkie dane" + "value" : "stuknij logo trzy razy, aby wymazać wszystkie dane (z potwierdzeniem)" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "toca três vezes no logótipo para limpar todos os dados de imediato" + "value" : "toque três vezes no logótipo para apagar todos os dados (com confirmação)" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "toque o logo três vezes para limpar todos os dados instantaneamente" + "value" : "toque três vezes no logo para apagar todos os dados (com confirmação)" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "тройной тап по логотипу мгновенно очищает все данные" + "value" : "трижды коснитесь логотипа, чтобы стереть все данные (с подтверждением)" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "tryck tre gånger på logotypen för att radera all data direkt" + "value" : "trippeltryck på loggan för att radera all data (med bekräftelse)" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எல்லா தரவையும் உடனடியாக நீக்க லோகோவை மூன்று முறைத் தட்டுங்கள்" + "value" : "எல்லா தரவையும் அழிக்க லோகோவை மூன்று முறை தட்டவும் (உறுதிப்படுத்தலுடன்)" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "แตะโลโก้สามครั้งเพื่อลบข้อมูลทั้งหมดทันที" + "value" : "แตะโลโก้สามครั้งเพื่อล้างข้อมูลทั้งหมด (มีการยืนยัน)" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "logoya üç kez dokunun, tüm veriler anında silinsin" + "value" : "tüm verileri silmek için logoya üç kez dokunun (onaylı)" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "тричі торкни логотип, щоб миттєво стерти всі дані" + "value" : "тричі торкніться логотипа, щоб стерти всі дані (з підтвердженням)" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تمام ڈیٹا فوری صاف کرنے کیلئے لوگو پر تین بار ٹیپ کریں" + "value" : "تمام ڈیٹا مٹانے کے لیے لوگو پر تین بار ٹیپ کریں (تصدیق کے ساتھ)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "chạm logo ba lần để xóa toàn bộ dữ liệu ngay lập tức" + "value" : "chạm ba lần vào logo để xoá toàn bộ dữ liệu (có xác nhận)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "三击标志立即清除全部数据" + "value" : "连点三次标志以清除所有数据(需确认)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "三擊標誌立即清除全部數據" + "value" : "連點三次標誌以清除所有資料(需確認)" } } } @@ -14522,181 +14708,181 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "يمسح كل الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه فورًا." + "value" : "يمسح جميع الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه." } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "সব বার্তা, কী ও পরিচয় মুছে দেয়। bitchat/ লোগোতে তিনবার ট্যাপ করলেও সঙ্গে সঙ্গে একই কাজ হয়।" + "value" : "সব বার্তা, কী এবং পরিচয় মুছে ফেলে। bitchat/ লোগোতে তিনবার ট্যাপ করলেও একই কাজ হয়।" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "löscht alle nachrichten, schlüssel und identität. dreimaliges tippen auf das bitchat/-logo macht dasselbe, sofort." + "value" : "löscht alle nachrichten, schlüssel und die identität. dreimaliges tippen aufs bitchat/-logo macht dasselbe." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly." + "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same." } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logotipo de bitchat/ hace lo mismo, al instante." + "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logo de bitchat/ hace lo mismo." } }, "fa" : { "stringUnit" : { "state" : "translated", - "value" : "همهٔ پیام‌ها، کلیدها و هویت را پاک می‌کند. سه بار زدن روی لوگوی bitchat/ هم همین کار را فوری انجام می‌دهد." + "value" : "همه پیام‌ها، کلیدها و هویت را پاک می‌کند. سه بار زدن روی لوگوی bitchat/ همین کار را می‌کند." } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ang pag-tap nang tatlong beses sa logo ng bitchat/ ay pareho ang ginagawa, agad-agad." + "value" : "binubura ang lahat ng mensahe, key, at pagkakakilanlan. ganoon din ang mangyayari kapag na-tap nang tatlong beses ang bitchat/ logo." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "efface tous les messages, les clés et l'identité. taper trois fois sur le logo bitchat/ fait la même chose, instantanément." + "value" : "efface tous les messages, les clés et l'identité. toucher trois fois le logo bitchat/ fait la même chose." } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר, מייד." + "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר." } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी तुरंत यही होता है।" + "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी यही होता है।" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama, seketika." + "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso, all'istante." + "value" : "cancella tutti i messaggi, le chiavi e l'identità. toccare tre volte il logo bitchat/ fa lo stesso." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "すべてのメッセージ、鍵、アイデンティティを消去します。bitchat/ ロゴを3回タップしても同じことが即座に行われます。" + "value" : "すべてのメッセージ・鍵・アイデンティティを消去します。bitchat/ ロゴの3回タップでも同じことができます。" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 즉시 같은 동작이 실행됩니다." + "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 동일합니다." } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "menghapus semua pesan, kunci, dan identiti. mengetuk logo bitchat/ tiga kali melakukan perkara yang sama, serta-merta." + "value" : "memadamkan semua mesej, kunci dan identiti. mengetik logo bitchat/ tiga kali melakukan perkara yang sama." } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "सबै सन्देश, कुञ्जी र पहिचान मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि तुरुन्तै त्यही हुन्छ।" + "value" : "सबै सन्देश, कुञ्जी र परिचय मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि त्यही हुन्छ।" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde, direct." + "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde." } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "usuwa wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo, natychmiast." + "value" : "wymazuje wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo." } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo, de imediato." + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logótipo bitchat/ faz o mesmo." } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo, instantaneamente." + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "стирает все сообщения, ключи и личность. тройной тап по логотипу bitchat/ делает то же самое, мгновенно." + "value" : "стирает все сообщения, ключи и идентичность. тройное касание логотипа bitchat/ делает то же самое." } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "raderar alla meddelanden, nycklar och identitet. att trycka tre gånger på bitchat/-logotypen gör detsamma, direkt." + "value" : "raderar alla meddelanden, nycklar och identitet. att trippeltrycka på bitchat/-loggan gör samma sak." } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "எல்லா செய்திகள், சாவிகள், அடையாளத்தையும் அழிக்கிறது. bitchat/ லோகோவை மூன்று முறைத் தட்டினாலும் உடனடியாக அதுவே நடக்கும்." + "value" : "எல்லா செய்திகள், விசைகள் மற்றும் அடையாளத்தை அழிக்கிறது. bitchat/ லோகோவை மூன்று முறை தட்டினாலும் இதுவே நடக்கும்." } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งก็ทำแบบเดียวกันทันที" + "value" : "ลบข้อความ กุญแจ และตัวตนทั้งหมด การแตะโลโก้ bitchat/ สามครั้งให้ผลเหมือนกัน" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını anında yapar." + "value" : "tüm mesajları, anahtarları ve kimliği siler. bitchat/ logosuna üç kez dokunmak da aynısını yapar." } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "стирає всі повідомлення, ключі та особистість. потрійний дотик до логотипа bitchat/ робить те саме, миттєво." + "value" : "стирає всі повідомлення, ключі та ідентичність. потрійне торкання логотипа bitchat/ робить те саме." } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی فوراً یہی ہوتا ہے۔" + "value" : "تمام پیغامات، کلیدیں اور شناخت مٹا دیتا ہے۔ bitchat/ لوگو پر تین بار ٹیپ کرنے سے بھی یہی ہوتا ہے۔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "xóa toàn bộ tin nhắn, khóa và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự, ngay lập tức." + "value" : "xoá toàn bộ tin nhắn, khoá và danh tính. chạm ba lần vào logo bitchat/ cũng làm điều tương tự." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "抹除所有消息、密钥和身份。三击 bitchat/ 标志也会立即执行相同操作。" + "value" : "清除所有消息、密钥和身份。连点三次 bitchat/ 标志效果相同。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "抹除所有訊息、金鑰和身分。三擊 bitchat/ 標誌也會立即執行相同操作。" + "value" : "清除所有訊息、金鑰和身分。連點三次 bitchat/ 標誌效果相同。" } } } @@ -67355,6 +67541,192 @@ } } }, + "system.panic.completed" : { + "comment" : "System message confirming a successful panic wipe", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم مسح جميع البيانات — تم إنشاء هوية جديدة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব ডেটা মুছে ফেলা হয়েছে — নতুন পরিচয় তৈরি হয়েছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle daten gelöscht — neue identität erstellt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "all data wiped — new identity created" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos los datos borrados — nueva identidad creada" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "همه داده‌ها پاک شد — هویت جدید ساخته شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nabura na ang lahat ng data — gumawa ng bagong pagkakakilanlan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "toutes les données effacées — nouvelle identité créée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כל הנתונים נמחקו — נוצרה זהות חדשה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सारा डेटा मिटा दिया गया — नई पहचान बनाई गई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "semua data dihapus — identitas baru dibuat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tutti i dati cancellati — nuova identità creata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべてのデータを消去しました — 新しいアイデンティティを作成しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 데이터 삭제됨 — 새 신원 생성됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "semua data dipadamkan — identiti baharu dicipta" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै डेटा मेटाइयो — नयाँ परिचय सिर्जना गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle gegevens gewist — nieuwe identiteit aangemaakt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wszystkie dane wymazane — utworzono nową tożsamość" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos os dados apagados — nova identidade criada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "todos os dados apagados — nova identidade criada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "все данные стёрты — создана новая идентичность" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "all data raderad — ny identitet skapad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா தரவும் அழிக்கப்பட்டது — புதிய அடையாளம் உருவாக்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลทั้งหมดแล้ว — สร้างตัวตนใหม่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tüm veriler silindi — yeni kimlik oluşturuldu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "усі дані стерто — створено нову ідентичність" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام ڈیٹا مٹا دیا گیا — نئی شناخت بنائی گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã xoá toàn bộ dữ liệu — đã tạo danh tính mới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除所有数据 — 已创建新身份" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除所有資料 — 已建立新身分" + } + } + } + }, "system.chat.blocked" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 12adfda9..7dd3dd8f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1771,6 +1771,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage panicNetworkLifecycle.restart() } + // In a duress scenario "did it work?" must not be a guess — the + // natural response to uncertainty is to trigger the wipe again. + // The failure case surfaces separately via `panicRecoveryBlocked`. + addMeshOnlySystemMessage( + String(localized: "system.panic.completed", defaultValue: "all data wiped — new identity created", comment: "System message confirming a successful panic wipe") + ) + return true } diff --git a/bitchat/Views/Components/PanicWipeBlockedBanner.swift b/bitchat/Views/Components/PanicWipeBlockedBanner.swift new file mode 100644 index 00000000..9039caf0 --- /dev/null +++ b/bitchat/Views/Components/PanicWipeBlockedBanner.swift @@ -0,0 +1,42 @@ +// +// PanicWipeBlockedBanner.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Shown under the header while a panic wipe has not committed +/// (`ChatViewModel.panicRecoveryBlocked`). A wipe that fails must be as loud +/// as the wipe itself: the person who triggered it may be relying on the +/// device being clean, and networking stays disabled until a relaunch retries +/// the transaction — without this banner the app just looks silently dead. +struct PanicWipeBlockedBanner: View { + @ThemedPalette private var palette + + private var message: String { + String( + localized: "content.banner.panic_blocked", + defaultValue: "wipe incomplete — some data may remain. quit and reopen bitchat to retry.", + comment: "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe" + ) + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .bitchatFont(size: 11, weight: .semibold) + Text(message) + .bitchatFont(size: 11) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.12)) + .accessibilityElement(children: .combine) + } +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index bd3af7f2..f857ba38 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -51,11 +51,20 @@ struct ContentHeaderView: View { // cluster at priority 3 never gives up width. .layoutPriority(2) .onTapGesture(count: 3) { - appChromeModel.panicClearAllData() + // Confirm before destroying: the same logo is the single-tap + // App Info entry point and sits beside the nickname field, so + // a fumbled tap must never be able to wipe the device. The + // dialog matches the Settings-pane panic button; under duress + // it costs one extra tap. + appChromeModel.requestPanicWipe() } .onTapGesture(count: 1) { appChromeModel.presentAppInfo() } + // The confirmation dialog itself is hosted on ContentView + // (next to the failed-wipe banner), not on this Text: a host + // that can be covered or removed could take the pending + // dialog down with it. // This is the only entry point to App Info, but it reads as // static text; surface the tap. (The triple-tap panic wipe // stays undiscoverable on purpose — it's destructive.) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 1fe95c75..6ef956ed 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -489,14 +489,38 @@ struct ContentView: View { } private var headerView: some View { - ContentHeaderView( - showSidebar: $showSidebar, - showVerifySheet: $showVerifySheet, - isNicknameFieldFocused: $isNicknameFieldFocused, - headerHeight: headerHeight, - headerPeerIconSize: headerPeerIconSize, - headerPeerCountFontSize: headerPeerCountFontSize - ) + VStack(spacing: 0) { + ContentHeaderView( + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet, + isNicknameFieldFocused: $isNicknameFieldFocused, + headerHeight: headerHeight, + headerPeerIconSize: headerPeerIconSize, + headerPeerCountFontSize: headerPeerCountFontSize + ) + + if appChromeModel.panicWipeBlocked { + PanicWipeBlockedBanner() + } + } + // Hosted here rather than on the logo Text so the pending dialog + // survives whatever happens to the header chrome. + .confirmationDialog( + Text( + String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") + ), + isPresented: $appChromeModel.showPanicConfirmation, + titleVisibility: .visible + ) { + Button(role: .destructive) { + appChromeModel.panicClearAllData() + } label: { + Text( + String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") + ) + } + Button("common.cancel", role: .cancel) {} + } } private var publicMessageList: some View { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 360c89ab..1ec52532 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -458,6 +458,54 @@ struct AppArchitectureTests { #expect(chromeModel.showingFingerprintFor == nil) } + @Test("Triple-tap panic entry point only raises the confirmation dialog") + @MainActor + func requestPanicWipeAsksBeforeDestroying() { + let viewModel = makeArchitectureViewModel() + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + viewModel.seedPublicMessages([ + BitchatMessage( + id: "keep-1", + sender: "Tester", + content: "still here", + timestamp: Date(), + isRelay: false + ) + ]) + + chromeModel.requestPanicWipe() + + // The gesture must never wipe directly — a fumbled tap on the logo + // (single-tap opens App Info) cannot be allowed to destroy identity. + #expect(chromeModel.showPanicConfirmation) + #expect(viewModel.messages.map(\.id) == ["keep-1"]) + } + + @Test("Panic wipe dismisses chrome sheets so its outcome is visible") + @MainActor + func panicWipeDismissesChromePresentation() { + let viewModel = makeArchitectureViewModel() + let privateInboxModel = PrivateInboxModel(conversations: ConversationStore()) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + + // The App Info danger-zone path wipes while its own sheet is up; the + // success message and the failed-wipe banner both render on the root + // timeline, so any sheet left presented would hide the one signal + // that says whether the wipe worked. + chromeModel.presentAppInfo() + chromeModel.isLocationChannelsSheetPresented = true + chromeModel.presentNotices() + chromeModel.showFingerprint(for: PeerID(str: "peer-3")) + + chromeModel.panicClearAllData() + + #expect(!chromeModel.isAppInfoPresented) + #expect(!chromeModel.isLocationChannelsSheetPresented) + #expect(!chromeModel.isNoticesSheetPresented) + #expect(chromeModel.showingFingerprintFor == nil) + } + @Test("Recent chats list direct conversations with people absent from the rosters") @MainActor func peerListModelSurfacesRecentChatsForAbsentPeers() async { diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 0393b3ce..35ab2975 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -2263,7 +2263,9 @@ struct ChatViewModelPanicTests { // After panic, emergency disconnect should be called #expect(transport.emergencyDisconnectCallCount == 1) - #expect(viewModel.messages.isEmpty) + // Pre-panic content is gone; the only survivor is the system message + // confirming the wipe (in duress, "did it work?" must not be a guess). + #expect(viewModel.messages.map(\.sender) == ["system"]) #expect(viewModel.privateChats.isEmpty) #expect(viewModel.unreadPrivateMessages.isEmpty) #expect(viewModel.selectedPrivateChatPeer == nil) From 5b592c8bae5d692abe56fc309362470604abf25e Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:47:53 +0200 Subject: [PATCH 10/14] Add a persistent connectivity banner and stop the radar lying (#1597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a persistent connectivity banner and stop the radar lying Two "the app looks fine while it isn't" problems from the UX audit: - Bluetooth-off had exactly one signal: a dismissible alert. Once dismissed, the app looked completely normal — empty timeline, radar sweeping "searching for people" — while the radio was off. The radar animation now requires a radio that can actually scan, and a persistent red banner under the header carries the state instead: off (tap → settings), denied (tap → settings), or unsupported (mesh unavailable; location channels still work). - A Tor bootstrap stall was only ever announced inside geohash timelines (addGeohashOnlySystemMessage), so someone sitting in #mesh or a DM whose messages route over Nostr got silence and hanging "sent" states. The stall now also drives a chrome-level torBlocked flag and the same banner: "tor can't connect — internet features are paused. mesh still works." The banner renders nothing when everything is healthy, and .unknown/ .resetting radio states deliberately stay quiet — a false "bluetooth is off" flash at launch would be the same kind of lie. Priority and quiet-start behavior are pinned by ConnectivityIssueTests. 4 new strings, all 30 locales. Co-Authored-By: Claude Fable 5 * Review fixes: banner in the sheet, real fix targets, tor-clear tests - The people/DM sheet covers the root header, so the connectivity banner vanished exactly where people sit longest. It now mirrors into the sheet via a top safe-area inset over both the people list and the DM view. - "tap to fix" for powered-off Bluetooth opened the macOS privacy permission pane, where an already-authorized person can't fix anything. New SystemSettings.bluetoothPower routes to the Bluetooth pane; denied still goes to the privacy anchor. The one-shot alert's settings button routes by state the same way. - Tests pin that torBlocked clears on tor-ready and on preference change (no stale "tor can't connect" after toggling tor off). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 7 + bitchat/Localizable.xcstrings | 744 ++++++++++++++++++ bitchat/Utils/SystemSettings.swift | 16 +- bitchat/ViewModels/ChatViewModel.swift | 4 + .../Extensions/ChatViewModel+Tor.swift | 5 + .../Components/ConnectivityStatusBanner.swift | 116 +++ .../Views/Components/MeshEmptyStateView.swift | 10 +- bitchat/Views/ContentSheetViews.swift | 17 +- bitchat/Views/ContentView.swift | 14 +- bitchat/Views/MessageListView.swift | 17 +- bitchatTests/ChatViewModelTorTests.swift | 21 +- bitchatTests/ConnectivityStatusTests.swift | 31 + 12 files changed, 990 insertions(+), 12 deletions(-) create mode 100644 bitchat/Views/Components/ConnectivityStatusBanner.swift create mode 100644 bitchatTests/ConnectivityStatusTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index 8d369c5b..51cadfb0 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -17,6 +17,9 @@ final class AppChromeModel: ObservableObject { @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown + /// Tor bootstrap has stalled (network likely blocks it); drives the + /// connectivity banner. Mirrored from `ChatViewModel.torBlocked`. + @Published private(set) var torBlocked = false @Published var showScreenshotPrivacyWarning = false /// Triple-tapping the logo asks first; the dialog lives on the header. @Published var showPanicConfirmation = false @@ -168,6 +171,10 @@ final class AppChromeModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$bluetoothState) + chatViewModel.$torBlocked + .receive(on: DispatchQueue.main) + .assign(to: &$torBlocked) + chatViewModel.$panicRecoveryBlocked .receive(on: DispatchQueue.main) .assign(to: &$panicWipeBlocked) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7f393629..48d05c3a 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2419,6 +2419,750 @@ } } }, + "content.banner.bluetooth_denied" : { + "comment" : "Persistent banner while Bluetooth permission is denied; tapping opens system settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم رفض الوصول إلى البلوتوث — الشبكة غير متصلة. انقر لفتح الإعدادات." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্লুটুথ অ্যাক্সেস অস্বীকৃত — মেশ অফলাইন। সেটিংস খুলতে ট্যাপ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-zugriff verweigert — mesh ist offline. tippen, um die einstellungen zu öffnen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth access denied — mesh is offline. tap to open settings." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "acceso a bluetooth denegado — la malla está desconectada. toca para abrir ajustes." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دسترسی بلوتوث رد شد — مش آفلاین است. برای باز کردن تنظیمات بزنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tinanggihan ang access sa bluetooth — offline ang mesh. i-tap para buksan ang settings." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "accès bluetooth refusé — le mesh est hors ligne. touchez pour ouvrir les réglages." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגישה לבלוטות' נדחתה — הרשת לא מקוונת. הקישו לפתיחת ההגדרות." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लूटूथ की अनुमति नहीं है — मेश ऑफ़लाइन है। सेटिंग्स खोलने के लिए टैप करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses bluetooth ditolak — mesh offline. ketuk untuk membuka pengaturan." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "accesso bluetooth negato — la mesh è offline. tocca per aprire le impostazioni." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth へのアクセスが拒否されています — メッシュはオフラインです。タップして設定を開きます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "블루투스 접근이 거부되었습니다 — 메시가 오프라인입니다. 탭하여 설정을 여세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses bluetooth ditolak — mesh di luar talian. ketik untuk membuka tetapan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लुटुथ पहुँच अस्वीकृत — मेश अफलाइन छ। सेटिङ खोल्न ट्याप गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-toegang geweigerd — mesh is offline. tik om instellingen te openen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odmowa dostępu do bluetooth — sieć mesh jest offline. stuknij, aby otworzyć ustawienia." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "acesso ao bluetooth negado — a mesh está offline. toca para abrir as definições." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "acesso ao bluetooth negado — a mesh está offline. toque para abrir os ajustes." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "доступ к bluetooth запрещён — mesh-сеть недоступна. нажмите, чтобы открыть настройки." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth-åtkomst nekad — mesh är offline. tryck för att öppna inställningar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "புளூடூத் அணுகல் மறுக்கப்பட்டது — மெஷ் ஆஃப்லைனில் உள்ளது. அமைப்புகளைத் திறக்கத் தட்டவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การเข้าถึงบลูทูธถูกปฏิเสธ — เมชออฟไลน์ แตะเพื่อเปิดการตั้งค่า" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth erişimi reddedildi — mesh çevrimdışı. ayarları açmak için dokunun." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "доступ до bluetooth заборонено — mesh-мережа офлайн. торкніться, щоб відкрити налаштування." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوٹوتھ رسائی مسترد — میش آف لائن ہے۔ سیٹنگز کھولنے کے لیے ٹیپ کریں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "quyền truy cập bluetooth bị từ chối — mesh đang ngoại tuyến. chạm để mở cài đặt." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙权限被拒绝 — 网状网络已离线。点按打开设置。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "藍牙權限遭拒 — 網狀網路已離線。點按開啟設定。" + } + } + } + }, + "content.banner.bluetooth_off" : { + "comment" : "Persistent banner while Bluetooth is switched off; tapping opens system settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "البلوتوث مطفأ — لا يمكن لمن حولك الوصول إليك. انقر للإصلاح." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্লুটুথ বন্ধ — কাছের কেউ আপনার সঙ্গে যোগাযোগ করতে পারবে না। ঠিক করতে ট্যাপ করুন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth ist aus — niemand in der nähe kann dich erreichen. zum beheben tippen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth is off — nobody nearby can reach you. tap to fix." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth está apagado — nadie cerca puede contactarte. toca para arreglarlo." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوتوث خاموش است — هیچ‌کس در نزدیکی نمی‌تواند به شما برسد. برای رفع مشکل بزنید." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-off ang bluetooth — walang makakaabot sa iyo na kalapit. i-tap para ayusin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "le bluetooth est désactivé — personne à proximité ne peut vous joindre. touchez pour corriger." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הבלוטות' כבוי — אף אחד בסביבה לא יכול להגיע אליך. הקישו לתיקון." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लूटूथ बंद है — आस-पास का कोई आप तक नहीं पहुँच सकता। ठीक करने के लिए टैप करें।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth mati — tidak ada orang di sekitar yang dapat menghubungi anda. ketuk untuk memperbaiki." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "il bluetooth è spento — nessuno nelle vicinanze può raggiungerti. tocca per risolvere." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth がオフです — 近くの人と通信できません。タップして設定へ。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "블루투스가 꺼져 있습니다 — 주변 누구와도 연결할 수 없습니다. 탭하여 해결하세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth dimatikan — tiada sesiapa berdekatan boleh menghubungi anda. ketik untuk membaiki." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लुटुथ बन्द छ — नजिकको कोही तपाईंसम्म पुग्न सक्दैन। मिलाउन ट्याप गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth staat uit — niemand in de buurt kan je bereiken. tik om dit op te lossen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth jest wyłączony — nikt w pobliżu nie może się z tobą połączyć. stuknij, aby naprawić." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bluetooth está desligado — ninguém por perto te consegue alcançar. toca para corrigir." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o bluetooth está desligado — ninguém por perto consegue te alcançar. toque para corrigir." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth выключен — никто поблизости не может с вами связаться. нажмите, чтобы исправить." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth är av — ingen i närheten kan nå dig. tryck för att åtgärda." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "புளூடூத் அணைக்கப்பட்டுள்ளது — அருகிலுள்ள யாரும் உங்களை அடைய முடியாது. சரிசெய்யத் தட்டவும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บลูทูธปิดอยู่ — คนใกล้เคียงติดต่อคุณไม่ได้ แตะเพื่อแก้ไข" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth kapalı — yakınınızdaki hiç kimse size ulaşamaz. düzeltmek için dokunun." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth вимкнено — ніхто поблизу не може з вами зв'язатися. торкніться, щоб виправити." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلوٹوتھ بند ہے — قریب کا کوئی آپ تک نہیں پہنچ سکتا۔ ٹھیک کرنے کے لیے ٹیپ کریں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bluetooth đang tắt — không ai gần bạn có thể liên lạc với bạn. chạm để khắc phục." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙已关闭 — 附近的人无法联系到你。点按以修复。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "藍牙已關閉 — 附近的人無法聯繫到你。點按以修復。" + } + } + } + }, + "content.banner.bluetooth_unsupported" : { + "comment" : "Persistent banner on devices without Bluetooth support", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يوجد بلوتوث على هذا الجهاز — الشبكة غير متاحة. قنوات الموقع لا تزال تعمل عبر الإنترنت." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই ডিভাইসে ব্লুটুথ নেই — মেশ অনুপলব্ধ। লোকেশন চ্যানেল এখনও ইন্টারনেটে কাজ করে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kein bluetooth auf diesem gerät — mesh ist nicht verfügbar. standortkanäle funktionieren weiter übers internet." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no bluetooth on this device — mesh is unavailable. location channels still work over the internet." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo no tiene bluetooth — la malla no está disponible. los canales de ubicación siguen funcionando por internet." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این دستگاه بلوتوث ندارد — مش در دسترس نیست. کانال‌های مکان همچنان از طریق اینترنت کار می‌کنند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang bluetooth ang device na ito — hindi available ang mesh. gumagana pa rin ang mga location channel sa internet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas de bluetooth sur cet appareil — le mesh est indisponible. les canaux de localisation fonctionnent toujours via internet." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין בלוטות' במכשיר הזה — הרשת לא זמינה. ערוצי מיקום עדיין עובדים דרך האינטרנט." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस डिवाइस में ब्लूटूथ नहीं है — मेश अनुपलब्ध है। लोकेशन चैनल अब भी इंटरनेट पर काम करते हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perangkat ini tidak memiliki bluetooth — mesh tidak tersedia. kanal lokasi tetap berfungsi lewat internet." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo dispositivo non ha il bluetooth — la mesh non è disponibile. i canali di posizione funzionano comunque via internet." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この端末に bluetooth がありません — メッシュは利用できません。ロケーションチャンネルはインターネット経由で利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에는 블루투스가 없습니다 — 메시를 사용할 수 없습니다. 위치 채널은 인터넷으로 계속 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada bluetooth pada peranti ini — mesh tidak tersedia. saluran lokasi masih berfungsi melalui internet." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो यन्त्रमा ब्लुटुथ छैन — मेश उपलब्ध छैन। स्थान च्यानलहरू अझै इन्टरनेटमा चल्छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen bluetooth op dit apparaat — mesh is niet beschikbaar. locatiekanalen werken nog via internet." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "brak bluetooth w tym urządzeniu — sieć mesh jest niedostępna. kanały lokalizacji nadal działają przez internet." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam a funcionar pela internet." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "este dispositivo não tem bluetooth — a mesh está indisponível. os canais de localização continuam funcionando pela internet." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "на этом устройстве нет bluetooth — mesh-сеть недоступна. каналы мест по-прежнему работают через интернет." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen bluetooth på den här enheten — mesh är inte tillgängligt. platskanaler fungerar fortfarande via internet." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்தச் சாதனத்தில் புளூடூத் இல்லை — மெஷ் கிடைக்காது. இருப்பிடச் சேனல்கள் இணையம் வழியாக இயங்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อุปกรณ์นี้ไม่มีบลูทูธ — ใช้เมชไม่ได้ ช่องตำแหน่งยังใช้งานผ่านอินเทอร์เน็ตได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu cihazda bluetooth yok — mesh kullanılamıyor. konum kanalları internet üzerinden çalışmaya devam ediyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "на цьому пристрої немає bluetooth — mesh-мережа недоступна. канали місць далі працюють через інтернет." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس ڈیوائس میں بلوٹوتھ نہیں — میش دستیاب نہیں۔ لوکیشن چینلز انٹرنیٹ پر بدستور کام کرتے ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thiết bị này không có bluetooth — mesh không khả dụng. các kênh vị trí vẫn hoạt động qua internet." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备没有蓝牙 — 网状网络不可用。位置频道仍可通过互联网使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置沒有藍牙 — 網狀網路無法使用。位置頻道仍可透過網際網路使用。" + } + } + } + }, + "content.banner.tor_blocked" : { + "comment" : "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتعذر على tor الاتصال — ميزات الإنترنت متوقفة مؤقتًا. الشبكة لا تزال تعمل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor সংযোগ করতে পারছে না — ইন্টারনেট ফিচার স্থগিত। মেশ এখনও কাজ করছে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kann sich nicht verbinden — internetfunktionen pausieren. mesh funktioniert weiter." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor can't connect — internet features are paused. mesh still works." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor no puede conectarse — las funciones de internet están en pausa. la malla sigue funcionando." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor نمی‌تواند متصل شود — قابلیت‌های اینترنتی متوقف‌اند. مش همچنان کار می‌کند." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi makakonekta ang tor — naka-pause ang mga internet feature. gumagana pa rin ang mesh." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor ne peut pas se connecter — les fonctions internet sont en pause. le mesh fonctionne toujours." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor לא מצליח להתחבר — תכונות האינטרנט מושהות. הרשת עדיין עובדת." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor कनेक्ट नहीं हो पा रहा — इंटरनेट सुविधाएँ रुकी हैं। मेश अब भी काम करता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat terhubung — fitur internet dijeda. mesh tetap berfungsi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor non riesce a connettersi — le funzioni internet sono in pausa. la mesh continua a funzionare." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor が接続できません — インターネット機能は一時停止中です。メッシュは引き続き使えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor가 연결되지 않습니다 — 인터넷 기능이 일시 중지되었습니다. 메시는 계속 작동합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor tidak dapat bersambung — ciri internet dijeda. mesh masih berfungsi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor जडान हुन सकेन — इन्टरनेट सुविधाहरू रोकिएका छन्। मेश अझै चल्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kan geen verbinding maken — internetfuncties zijn gepauzeerd. mesh werkt nog." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor nie może się połączyć — funkcje internetowe są wstrzymane. sieć mesh nadal działa." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não consegue ligar-se — as funções de internet estão em pausa. a mesh continua a funcionar." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o tor não consegue se conectar — os recursos de internet estão pausados. a mesh continua funcionando." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не может подключиться — интернет-функции приостановлены. mesh-сеть продолжает работать." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor kan inte ansluta — internetfunktioner är pausade. mesh fungerar fortfarande." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor இணைக்க முடியவில்லை — இணைய அம்சங்கள் இடைநிறுத்தப்பட்டுள்ளன. மெஷ் தொடர்ந்து இயங்குகிறது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor เชื่อมต่อไม่ได้ — ฟีเจอร์อินเทอร์เน็ตหยุดชั่วคราว เมชยังใช้งานได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor bağlanamıyor — internet özellikleri duraklatıldı. mesh çalışmaya devam ediyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor не може під'єднатися — інтернет-функції призупинено. mesh-мережа далі працює." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor منسلک نہیں ہو پا رہا — انٹرنیٹ فیچرز رکے ہوئے ہیں۔ میش بدستور کام کرتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor không thể kết nối — các tính năng internet tạm dừng. mesh vẫn hoạt động." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 无法连接 — 互联网功能已暂停。网状网络仍可使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "tor 無法連線 — 網際網路功能已暫停。網狀網路仍可使用。" + } + } + } + }, "content.banner.panic_blocked" : { "comment" : "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe", "extractionState" : "manual", diff --git a/bitchat/Utils/SystemSettings.swift b/bitchat/Utils/SystemSettings.swift index ab552e07..2a739ea9 100644 --- a/bitchat/Utils/SystemSettings.swift +++ b/bitchat/Utils/SystemSettings.swift @@ -14,17 +14,22 @@ import AppKit enum SystemSettings { case bluetooth + /// The radio power toggle, distinct from the `.bluetooth` privacy + /// permission anchor: an already-authorized person whose radio is + /// switched off can't fix anything from the privacy pane. + case bluetoothPower case location case microphone #if os(macOS) private static let baseURL = "x-apple.systempreferences:com.apple.preference.security" - private var macPrivacyAnchor: String { + private var macURLString: String { switch self { - case .bluetooth: "Privacy_Bluetooth" - case .location: "Privacy_LocationServices" - case .microphone: "Privacy_Microphone" + case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth" + case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings" + case .location: "\(Self.baseURL)?Privacy_LocationServices" + case .microphone: "\(Self.baseURL)?Privacy_Microphone" } } #endif @@ -35,8 +40,7 @@ enum SystemSettings { UIApplication.shared.open(url) } #elseif os(macOS) - let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)" - if let url = URL(string: urlString) { + if let url = URL(string: macURLString) { NSWorkspace.shared.open(url) } #endif diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7dd3dd8f..63fba0f8 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -367,6 +367,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage var torRestartPending: Bool = false // Announce a stalled bootstrap once per attempt, not once per poll. var torStallAnnounced: Bool = false + // Live "tor is blocked" state for the connectivity banner. The system + // message above only reaches geohash timelines; this is the chrome-level + // signal that stays up until tor actually gets through. + @Published var torBlocked: Bool = false // Ensure we set up DM subscription only once per app session var nostrHandlersSetup: Bool = false var geoChannelCoordinator: GeoChannelCoordinator? diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift index 3bf7a5a1..3c1f9005 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Tor.swift @@ -40,6 +40,7 @@ extension ChatViewModel { @objc func handleTorDidBecomeReady() { Task { @MainActor in self.torStallAnnounced = false + self.torBlocked = false // Only announce "restarted" if we actually restarted this session if self.torRestartPending { // Post only in geohash channels (queue if not active) @@ -68,6 +69,7 @@ extension ChatViewModel { // runtime preference is what says whether anyone is waiting on // Tor. Turning Tor off mid-bootstrap must not read as blocking. guard NetworkActivationService.persistedTorPreference() else { return } + self.torBlocked = true guard !self.torStallAnnounced else { return } self.torStallAnnounced = true self.addGeohashOnlySystemMessage( @@ -86,6 +88,9 @@ extension ChatViewModel { self.torInitialReadyAnnounced = false self.torRestartPending = false self.torStallAnnounced = false + // Turning tor off means nobody is waiting on it; turning it on + // starts a fresh attempt. Either way the stall banner resets. + self.torBlocked = false } } } diff --git a/bitchat/Views/Components/ConnectivityStatusBanner.swift b/bitchat/Views/Components/ConnectivityStatusBanner.swift new file mode 100644 index 00000000..4caedccf --- /dev/null +++ b/bitchat/Views/Components/ConnectivityStatusBanner.swift @@ -0,0 +1,116 @@ +// +// ConnectivityStatusBanner.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CoreBluetooth +import SwiftUI + +/// The degraded state a banner should surface, in priority order. Bluetooth +/// outranks tor: without the radio the app's core promise (mesh) is dead, +/// while a tor stall only pauses internet features. +enum ConnectivityIssue: Equatable { + case bluetoothOff + case bluetoothDenied + case bluetoothUnsupported + case torBlocked + + /// Pure resolution so the banner's priority logic is testable without a + /// view. `.unknown`/`.resetting` deliberately resolve to nil — the radio + /// is still starting and a flash of "bluetooth is off" at launch would + /// be the same false signal this banner exists to prevent. + static func resolve(bluetoothState: CBManagerState, torBlocked: Bool) -> ConnectivityIssue? { + switch bluetoothState { + case .poweredOff: return .bluetoothOff + case .unauthorized: return .bluetoothDenied + case .unsupported: return .bluetoothUnsupported + case .poweredOn, .unknown, .resetting: break + @unknown default: break + } + return torBlocked ? .torBlocked : nil + } +} + +/// Persistent one-line banner under the header while connectivity is +/// degraded. The Bluetooth alert is a one-shot modal — once dismissed, the +/// app used to look completely normal (empty timeline, radar sweeping) +/// while the radio was off. Tor stalls were only ever announced inside +/// geohash timelines. This banner is the always-visible truth; it renders +/// nothing when everything is fine. +struct ConnectivityStatusBanner: View { + let issue: ConnectivityIssue + @ThemedPalette private var palette + + private var message: String { + switch issue { + case .bluetoothOff: + return String( + localized: "content.banner.bluetooth_off", + defaultValue: "bluetooth is off — nobody nearby can reach you. tap to fix.", + comment: "Persistent banner while Bluetooth is switched off; tapping opens system settings" + ) + case .bluetoothDenied: + return String( + localized: "content.banner.bluetooth_denied", + defaultValue: "bluetooth access denied — mesh is offline. tap to open settings.", + comment: "Persistent banner while Bluetooth permission is denied; tapping opens system settings" + ) + case .bluetoothUnsupported: + return String( + localized: "content.banner.bluetooth_unsupported", + defaultValue: "no bluetooth on this device — mesh is unavailable. location channels still work over the internet.", + comment: "Persistent banner on devices without Bluetooth support" + ) + case .torBlocked: + return String( + localized: "content.banner.tor_blocked", + defaultValue: "tor can't connect — internet features are paused. mesh still works.", + comment: "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it" + ) + } + } + + /// Bluetooth problems deep-link to where the fix actually lives — + /// powered-off goes to the radio controls, denied to the privacy + /// permission pane. The tor stall has no in-app remedy (the network is + /// blocking it), so that banner is inert. + private var settingsDestination: SystemSettings? { + switch issue { + case .bluetoothOff: return .bluetoothPower + case .bluetoothDenied: return .bluetooth + case .bluetoothUnsupported, .torBlocked: return nil + } + } + + var body: some View { + Group { + if let destination = settingsDestination { + Button(action: { destination.open() }) { + label + } + .buttonStyle(.plain) + } else { + label + } + } + } + + private var label: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: issue == .torBlocked ? "network.slash" : "antenna.radiowaves.left.and.right.slash") + .bitchatFont(size: 11, weight: .semibold) + Text(message) + .bitchatFont(size: 11) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.red.opacity(0.12)) + .accessibilityElement(children: .combine) + } +} diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift index a244d5f1..b225927d 100644 --- a/bitchat/Views/Components/MeshEmptyStateView.swift +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -19,6 +19,10 @@ struct MeshEmptyStateView: View { /// intro/help narration (the timeline isn't empty) and shrinks the /// radar, keeping the sightings tally and the live hints visible. var compact: Bool = false + /// Whether the radio can actually scan. With Bluetooth off or denied the + /// sweep must not run — an animated "searching" over a dead radio is an + /// actively false status display. Defaults to true for previews. + var bluetoothAvailable: Bool = true @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel @@ -69,8 +73,12 @@ struct MeshEmptyStateView: View { /// The radar means "searching for people": once anyone is connected or /// reachable on the mesh, the search is over and the sweep goes away. + /// And it only means that while the radio is actually on — the + /// connectivity banner carries the message when it isn't. private var isSearchingForPeers: Bool { - peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0 + bluetoothAvailable + && peerListModel.connectedMeshPeerCount == 0 + && peerListModel.reachableMeshPeerCount == 0 } var body: some View { diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 18c0f148..cf62debb 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -183,6 +183,18 @@ struct ContentPeopleSheetView: View { .environmentObject(verificationModel) } } + // This sheet covers the root header where the connectivity + // banner lives; a person sitting in the people list or a DM + // would otherwise get no persistent signal that the radio is + // off or tor is stalled. Mirror it here. + .safeAreaInset(edge: .top, spacing: 0) { + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } + } } .themedSheetBackground() .foregroundColor(palette.primary) @@ -285,7 +297,10 @@ struct ContentPeopleSheetView: View { isPresented: bluetoothAlertBinding ) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 6ef956ed..48d353c3 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -409,7 +409,10 @@ struct ContentView: View { }) .alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) { Button("content.alert.bluetooth_required.settings") { - SystemSettings.bluetooth.open() + // Powered-off needs the radio controls, not the privacy pane. + (appChromeModel.bluetoothState == .poweredOff + ? SystemSettings.bluetoothPower + : SystemSettings.bluetooth).open() } Button("common.ok", role: .cancel) {} } message: { @@ -499,9 +502,18 @@ struct ContentView: View { headerPeerCountFontSize: headerPeerCountFontSize ) + // Failed-wipe outranks connectivity: "your data may still be + // here" matters more than "the radio is off". if appChromeModel.panicWipeBlocked { PanicWipeBlockedBanner() } + + if let issue = ConnectivityIssue.resolve( + bluetoothState: appChromeModel.bluetoothState, + torBlocked: appChromeModel.torBlocked + ) { + ConnectivityStatusBanner(issue: issue) + } } // Hosted here rather than on the logo Text so the pending dialog // survives whatever happens to the header chrome. diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 6e1c6b95..1894a347 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -6,6 +6,7 @@ // import BitFoundation +import CoreBluetooth import SwiftUI private struct MessageDisplayItem: Identifiable { @@ -176,7 +177,7 @@ struct MessageListView: View { // sightings, live hints) stays visible below it instead of // vanishing the moment echoes exist. if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) { - MeshEmptyStateView(compact: true) + MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan) .padding(.horizontal, 12) .padding(.top, 20) .padding(.bottom, 8) @@ -296,6 +297,15 @@ struct MessageListView: View { } private extension MessageListView { + /// Whether the radio could be scanning at all — the empty-state radar + /// must not sweep over a switched-off or denied radio. + var bluetoothCanScan: Bool { + switch appChromeModel.bluetoothState { + case .poweredOff, .unauthorized, .unsupported: return false + default: return true + } + } + var currentContextKey: String { if let peer = privatePeer { return "dm:\(peer)" @@ -373,7 +383,10 @@ private extension MessageListView { VStack(alignment: .leading, spacing: 6) { switch locationChannelsModel.selectedChannel { case .mesh: - MeshEmptyStateView(fillHeight: max(0, fillHeight - 24)) + MeshEmptyStateView( + fillHeight: max(0, fillHeight - 24), + bluetoothAvailable: bluetoothCanScan + ) case .location(let channel): emptyStateLine( String( diff --git a/bitchatTests/ChatViewModelTorTests.swift b/bitchatTests/ChatViewModelTorTests.swift index d4697dd8..d5eba112 100644 --- a/bitchatTests/ChatViewModelTorTests.swift +++ b/bitchatTests/ChatViewModelTorTests.swift @@ -165,14 +165,33 @@ struct ChatViewModelTorTests { viewModel.torStatusAnnounced = true viewModel.torInitialReadyAnnounced = true viewModel.torRestartPending = true + viewModel.torBlocked = true // Action viewModel.handleTorPreferenceChanged(Notification(name: .init("test"))) try? await Task.sleep(nanoseconds: 100_000_000) - // Assert: all flags reset + // Assert: all flags reset — torBlocked drives the connectivity + // banner; toggling tor off must not leave a stale "tor can't + // connect" line for a network nobody is waiting on. #expect(!viewModel.torStatusAnnounced) #expect(!viewModel.torInitialReadyAnnounced) #expect(!viewModel.torRestartPending) + #expect(!viewModel.torBlocked) + } + + @Test @MainActor + func handleTorDidBecomeReady_clearsBlockedBanner() async { + let (viewModel, _) = makeTestableViewModel() + + // Setup: a stalled bootstrap raised the banner, then tor got through. + viewModel.torBlocked = true + + // Action + viewModel.handleTorDidBecomeReady() + try? await Task.sleep(nanoseconds: 100_000_000) + + // Assert + #expect(!viewModel.torBlocked) } } diff --git a/bitchatTests/ConnectivityStatusTests.swift b/bitchatTests/ConnectivityStatusTests.swift new file mode 100644 index 00000000..93ecc2fb --- /dev/null +++ b/bitchatTests/ConnectivityStatusTests.swift @@ -0,0 +1,31 @@ +// +// ConnectivityStatusTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import CoreBluetooth +import Testing +@testable import bitchat + +struct ConnectivityIssueTests { + + @Test("Bluetooth problems outrank a tor stall; healthy state shows nothing") + func resolvePrioritizesBluetoothOverTor() { + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOff, torBlocked: true) == .bluetoothOff) + #expect(ConnectivityIssue.resolve(bluetoothState: .unauthorized, torBlocked: false) == .bluetoothDenied) + #expect(ConnectivityIssue.resolve(bluetoothState: .unsupported, torBlocked: false) == .bluetoothUnsupported) + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: true) == .torBlocked) + #expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: false) == nil) + } + + @Test("A starting radio must not flash a false 'bluetooth is off' banner") + func resolveStaysQuietWhileRadioIsStarting() { + #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: false) == nil) + #expect(ConnectivityIssue.resolve(bluetoothState: .resetting, torBlocked: false) == nil) + // A tor stall still surfaces once known, even while the radio starts. + #expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: true) == .torBlocked) + } +} From dba67c1466b79855cb513ed68b07ae91c72ccfe2 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:12:50 +0200 Subject: [PATCH 11/14] Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout (#1653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Deflake AppArchitectureTests: raise the local waitUntil to settleTimeout The file-local waitUntil defaulted to 3s — below the documented minimumSettleTimeout floor — and the recent-chats geo-dedup wait missed it on a loaded runner right after merge (the Combine hop through receive(on: .main) was starved). Every caller waits for a condition to become true, so green runs return immediately and never pay the 30s deadline. Co-Authored-By: Claude Fable 5 * Fix the real geo-dedup race: re-assert tracker state on each poll The 30s deadline didn't help — the recent-chats geo-dedup wait wasn't slow, it was permanently broken under parallel swift-test load: the view model's own channel binding delivers its initial .mesh selection asynchronously and resets the active participant geohash when it lands (GeohashSubscriptionManager → setActiveParticipantGeohash(nil)), wiping the test's setup so the dedup could never happen. Reproduced locally with `swift test --parallel`; standalone runs never hit it. The wait now re-asserts setActiveGeohash + recordParticipant on every poll (both idempotent) — the same self-healing pattern the geohash timeline test already uses for singleton interference. Interference heals on the next poll; a genuine dedup failure still times out. 6× full parallel runs green. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchatTests/AppArchitectureTests.swift | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 1ec52532..747e737b 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -69,7 +69,11 @@ private func makeArchitectureMessage( @MainActor private func waitUntil( - timeoutNanoseconds: UInt64 = 3_000_000_000, + // Settle deadline, not a latency budget (see TestConstants.settleTimeout): + // the old 3s default flaked on CI the moment a Combine hop through + // receive(on: .main) was starved. Every caller waits for a condition to + // become true, so passing runs return immediately. + timeoutNanoseconds: UInt64 = UInt64(TestConstants.settleTimeout * 1_000_000_000), pollNanoseconds: UInt64 = 20_000_000, _ condition: @escaping @MainActor () -> Bool ) async { @@ -580,11 +584,19 @@ struct AppArchitectureTests { // While that person is visible in the geohash roster, the chat row // collapses — same absent-from-rosters contract as mesh. - viewModel.participantTracker.setActiveGeohash("u4pruy") - viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") - + // + // Re-assert the tracker state on every poll: the view model's own + // channel binding delivers its initial .mesh selection asynchronously + // and resets the active participant geohash when it lands + // (GeohashSubscriptionManager.setActiveParticipantGeohash(nil)) — on + // a loaded parallel runner that reset arrives AFTER this setup and + // the dedup can never happen. Both calls are idempotent, so the + // interference heals on the next poll while a genuine dedup failure + // still times out. await waitUntil { - !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } + viewModel.participantTracker.setActiveGeohash("u4pruy") + viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy") + return !peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer } } #expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }) #expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID]) From eca147191cd5db3423e8ebc872070dee53539b78 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:03:23 +0200 Subject: [PATCH 12/14] Add 26 code-referenced keys to the catalog + a test that closes the gap (#1654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 4836 ++++++++++++++++++ bitchatTests/LocalizationCoverageTests.swift | 41 + 2 files changed, 4877 insertions(+) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 48d05c3a..7a223545 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -1,6 +1,1866 @@ { "sourceLanguage" : "en", "strings" : { + "board.accessibility.post" : { + "comment" : "Accessibility label for the board post button", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نشر إعلان" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোটিশ পোস্ট করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aushang posten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post notice" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publicar aviso" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ارسال اطلاعیه" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mag-post ng abiso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publier une annonce" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרסום מודעה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोटिस पोस्ट करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posting pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pubblica avviso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせを投稿" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지 올리기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Siarkan notis" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना पोस्ट गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededeling plaatsen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opublikuj ogłoszenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publicar aviso" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publicar aviso" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опубликовать объявление" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publicera anslag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்பை வெளியிடு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โพสต์ประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duyuru paylaş" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Опублікувати оголошення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اعلان پوسٹ کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đăng thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "張貼公告" + } + } + } + }, + "board.accessibility.post_row" : { + "comment" : "Accessibility label for a board post row", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إعلان من %@: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর নোটিশ: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aushang von %@: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notice from %@: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso de %@: %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطلاعیه از %@: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abiso mula kay %@: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annonce de %@ : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודעה מאת %@: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ का नोटिस: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengumuman dari %@: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avviso da %@: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@さんからのお知らせ:%@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@님의 공지: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notis daripada %@: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@बाट सूचना: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mededeling van %@: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogłoszenie od %@: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso de %@: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso de %@: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Объявление от %@: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anslag från %@: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ இடமிருந்து அறிவிப்பு: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ประกาศจาก %@: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kişisinden duyuru: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оголошення від %@: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کی طرف سے اعلان: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo từ %@: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "来自 %@ 的公告:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "來自 %@ 的公告:%@" + } + } + } + }, + "board.action.delete" : { + "comment" : "Delete action for own board posts", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মুছে ফেলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "delete" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "burahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "supprimer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחיקה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "हटाओ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "elimina" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "削除" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "padam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेटाउनुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "apagar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "excluir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "удалить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ta bort" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நீக்கு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ลบ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "видалити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xoá" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除" + } + } + } + }, + "board.compose.expiry" : { + "comment" : "Label for the board post expiry picker", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنتهي الصلاحية خلال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেয়াদ শেষ হবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "läuft ab in" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "expires in" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "caduca en" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "منقضی می‌شود پس از" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-e-expire sa loob ng" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "expire dans" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פג תוקף בעוד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "समाप्त होगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kedaluwarsa dalam" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "scade tra" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効期限" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "만료까지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "luput dalam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "म्याद सकिने" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verloopt over" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wygasa za" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "expira em" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "expira em" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "истекает через" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "går ut om" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "காலாவதி நேரம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "หมดอายุใน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "geçerlilik süresi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "зникає через" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میعاد ختم ہونے میں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hết hạn sau" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有效期" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有效期限" + } + } + } + }, + "board.compose.expiry_days" : { + "comment" : "Expiry picker option, number of days abbreviated", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ي" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld দি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld روز" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldj" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld י׳" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld दि" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld hr" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldg" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld日" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld일" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld दिन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld д" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldd" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld நா" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld วัน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldg" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldд" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld دن" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld天" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld天" + } + } + } + }, + "board.compose.placeholder" : { + "comment" : "Placeholder for the board composer text field", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انشر إعلانًا…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটা নোটিশ পোস্ট করো…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "einen aushang posten…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "post a notice…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "publica un aviso…" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یک اطلاعیه بگذار…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-post ng abiso…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "publie une annonce…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרסמו מודעה…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कोई नोटिस लिखो…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "posting pengumuman…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "pubblica un avviso…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせを投稿…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공지를 올려보세요…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "siarkan notis…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना पोस्ट गर्नुहोस्…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "plaats een mededeling…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodaj ogłoszenie…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "publica um aviso…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "publique um aviso…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "напишите объявление…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "publicera ett anslag…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அறிவிப்பை வெளியிடுங்கள்…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โพสต์ประกาศ…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bir duyuru paylaş…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "напиши оголошення…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک اعلان پوسٹ کریں…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đăng một thông báo…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发布一条公告…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "張貼一則公告…" + } + } + } + }, + "board.compose.urgent" : { + "comment" : "Label for the urgent toggle in the board composer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عاجل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "জরুরি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dringend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فوری" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דחוף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ज़रूरी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mendesak" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "긴급" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "segera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जरुरी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dringend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pilne" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "срочно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "akut" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அவசரம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ด่วน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "acil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "терміново" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "فوری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khẩn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "紧急" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急" + } + } + } + }, + "board.empty_subtitle" : { + "comment" : "Subtitle shown when the board has no posts", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت أول إعلان للناس في الجوار." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশের মানুষদের জন্য প্রথম নোটিশটা পিন করো।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinne den ersten aushang für die leute hier in der gegend an." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin the first notice for people around here." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija el primer aviso para la gente de por aquí." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اولین اطلاعیه را برای آدم‌های این اطراف سنجاق کن." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-pin ang unang abiso para sa mga tao sa paligid." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle la première annonce pour les gens du coin." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמידו את המודעה הראשונה בשביל האנשים שבסביבה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास के लोगों के लिए पहला नोटिस पिन करो।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan pengumuman pertama untuk orang-orang di sekitar sini." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "appunta il primo avviso per le persone qui intorno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この辺りにいる人のために最初のお知らせをピン留めしましょう。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 주변 사람들을 위해 첫 공지를 고정해 보세요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan notis pertama untuk orang di sekitar sini." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ वरपरका मानिसहरूका लागि पहिलो सूचना पिन गर्नुहोस्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin de eerste mededeling voor mensen hier in de buurt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypnij pierwsze ogłoszenie dla osób w okolicy." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "afixa o primeiro aviso para as pessoas por aqui." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe o primeiro aviso para as pessoas por aqui." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепите первое объявление для людей поблизости." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "sätt upp det första anslaget för folk häromkring." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்குள்ள மக்களுக்காக முதல் அறிவிப்பைப் பொருத்துங்கள்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักประกาศแรกให้ผู้คนแถวนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buradaki insanlar için ilk duyuruyu sabitle." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріпи перше оголошення для людей поруч." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہاں آس پاس کے لوگوں کے لیے پہلا اعلان لگائیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim thông báo đầu tiên cho mọi người quanh đây." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "为附近的人钉上第一条公告吧。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "為附近的人釘上第一則公告吧。" + } + } + } + }, + "board.empty_title" : { + "comment" : "Title shown when the board has no posts", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا إعلانات بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখনও কোনো নোটিশ নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "noch keine aushänge" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no notices yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay avisos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنوز اطلاعیه‌ای نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wala pang abiso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas encore d'annonces" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין עדיין מודעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी कोई नोटिस नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada pengumuman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ancora nessun avviso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お知らせはまだありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 공지가 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada notis" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिलेसम्म कुनै सूचना छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nog geen mededelingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie ma jeszcze ogłoszeń" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda não há avisos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum aviso ainda" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объявлений пока нет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inga anslag än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்னும் அறிவிப்புகள் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่มีประกาศ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "henüz duyuru yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "оголошень поки немає" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی کوئی اعلان نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chưa có thông báo nào" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还没有公告" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還沒有公告" + } + } + } + }, + "board.urgent_badge" : { + "comment" : "Badge shown on urgent board posts", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عاجل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "জরুরি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dringend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فوری" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דחוף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ज़रूरी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "mendesak" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "긴급" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "segera" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जरुरी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dringend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pilne" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "urgente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "срочно" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "akut" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அவசரம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ด่วน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "acil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "терміново" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "فوری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "khẩn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "紧急" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急" + } + } + } + }, "app_info.settings.relays.add" : { "comment" : "Button that adds the typed relay address to the list", "extractionState" : "manual", @@ -2419,6 +4279,2796 @@ } } }, + "content.delivery.reason.legacy_media_consent_required" : { + "comment" : "Failure reason when a legacy private-media send lacks per-send consent", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التأكيد مطلوب قبل الإرسال بدون التشفير التام بين الطرفين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠানোর আগে নিশ্চিতকরণ দরকার" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bestätigung erforderlich, bevor ohne Ende-zu-Ende-Verschlüsselung gesendet wird" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmation required before sending without end-to-end encryption" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se requiere confirmación antes de enviar sin cifrado de extremo a extremo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیش از ارسال بدون رمزگذاری سرتاسری، تأیید لازم است" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kailangan ng kumpirmasyon bago magpadala nang walang end-to-end encryption" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmation requise avant l'envoi sans chiffrement de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נדרש אישור לפני שליחה ללא הצפנה מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना भेजने से पहले पुष्टि ज़रूरी है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perlu konfirmasi sebelum mengirim tanpa enkripsi end-to-end" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "È richiesta una conferma prima di inviare senza crittografia end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エンドツーエンド暗号化なしで送信する前に確認が必要です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종단간 암호화 없이 보내려면 먼저 확인이 필요해요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pengesahan diperlukan sebelum menghantar tanpa penyulitan hujung ke hujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाउनुअघि पुष्टि आवश्यक छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bevestiging vereist voordat er zonder end-to-end-versleuteling wordt verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymagane potwierdzenie przed wysłaniem bez szyfrowania end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "É necessária confirmação antes de enviar sem encriptação de ponta a ponta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "É preciso confirmar antes de enviar sem criptografia de ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перед отправкой без сквозного шифрования требуется подтверждение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekräftelse krävs innan något skickas utan totalsträckskryptering" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்புவதற்கு முன் உறுதிப்படுத்தல் தேவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ต้องยืนยันก่อนส่งโดยไม่มีการเข้ารหัสแบบต้นทางถึงปลายทาง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uçtan uca şifreleme olmadan göndermeden önce onay gerekli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перед надсиланням без наскрізного шифрування потрібне підтвердження" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر بھیجنے سے پہلے تصدیق درکار ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cần xác nhận trước khi gửi mà không có mã hóa đầu cuối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在没有端到端加密的情况下发送前需要确认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在沒有端對端加密的情況下傳送前需要確認" + } + } + } + }, + "content.delivery.reason.legacy_media_declined" : { + "comment" : "Failure reason after declining the warning for a legacy clear private-media send", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم يُرسَل بدون التشفير التام بين الطرفين" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠানো হয়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Ende-zu-Ende-Verschlüsselung nicht gesendet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not sent without end-to-end encryption" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se envió sin cifrado de extremo a extremo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدون رمزگذاری سرتاسری ارسال نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hindi ipinadala nang walang end-to-end encryption" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non envoyé sans chiffrement de bout en bout" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא נשלח ללא הצפנה מקצה לקצה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना नहीं भेजा गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak terkirim tanpa enkripsi end-to-end" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non inviato senza crittografia end-to-end" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エンドツーエンド暗号化なしでは送信されませんでした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종단간 암호화 없이 전송되지 않았어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidak dihantar tanpa penyulitan hujung ke hujung" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाइएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niet verzonden zonder end-to-end-versleuteling" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie wysłano bez szyfrowania end-to-end" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não enviado sem encriptação de ponta a ponta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não enviado sem criptografia de ponta a ponta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не отправлено без сквозного шифрования" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skickades inte utan totalsträckskryptering" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்பப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่ได้ส่งเพราะไม่มีการเข้ารหัสแบบต้นทางถึงปลายทาง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uçtan uca şifreleme olmadan gönderilmedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не надіслано без наскрізного шифрування" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر نہیں بھیجا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa gửi vì không có mã hóa đầu cuối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缺少端到端加密,未发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "缺少端對端加密,未傳送" + } + } + } + }, + "content.delivery.reason.private_media_admission_expired" : { + "comment" : "Failure reason when private-media admission expires before fragment scheduling", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتهت مهلة نقل الوسائط قبل أن يبدأ" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া ট্রান্সফার শুরু হওয়ার আগেই সময় শেষ হয়ে গেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitüberschreitung, bevor die Medienübertragung starten konnte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media transfer timed out before it could start" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La transferencia multimedia expiró antes de poder empezar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مهلت انتقال رسانه پیش از شروع به پایان رسید" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nag-time out ang media transfer bago pa ito makapagsimula" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le transfert de média a expiré avant de pouvoir démarrer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תם הזמן הקצוב להעברת המדיה לפני שהספיקה להתחיל" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया ट्रांसफ़र शुरू होने से पहले ही समय समाप्त हो गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer media kehabisan waktu sebelum sempat dimulai" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il trasferimento del media è scaduto prima di poter iniziare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディア転送が開始前にタイムアウトしました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어 전송이 시작되기 전에 시간이 초과됐어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pemindahan media tamat masa sebelum sempat bermula" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया स्थानान्तरण सुरु हुनुअघि नै समय सकियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De mediaoverdracht is verlopen voordat die kon beginnen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przekroczono limit czasu, zanim transfer multimediów mógł się rozpocząć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A transferência de multimédia expirou antes de poder começar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A transferência de mídia expirou antes de poder começar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время ожидания передачи медиа истекло до её начала" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsgränsen för medieöverföringen gick ut innan den kunde starta" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியா பரிமாற்றம் தொடங்குவதற்கு முன்பே நேரம் முடிந்துவிட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การถ่ายโอนสื่อหมดเวลาก่อนที่จะได้เริ่ม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya aktarımı başlayamadan zaman aşımına uğradı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Час очікування передачі медіа вичерпано ще до її початку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا کی منتقلی شروع ہونے سے پہلے ہی وقت ختم ہو گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quá trình truyền media đã hết thời gian chờ trước khi kịp bắt đầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒体传输在开始前就已超时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "媒體傳輸在開始前就已逾時" + } + } + } + }, + "content.delivery.reason.private_media_admission_full" : { + "comment" : "Failure reason when too many private-media transfers are awaiting admission", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هناك عمليات نقل وسائط كثيرة قيد الانتظار؛ حاول مجددًا بعد قليل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অনেক বেশি মিডিয়া ট্রান্সফার অপেক্ষায় আছে; একটু পরে আবার চেষ্টা করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu viele Medienübertragungen warten gerade; versuch es gleich noch mal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Too many media transfers are waiting; try again shortly" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay demasiadas transferencias multimedia en espera; inténtalo de nuevo en un momento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتقال‌های رسانهٔ زیادی در انتظارند؛ کمی بعد دوباره تلاش کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masyadong maraming media transfer ang naghihintay; subukan ulit mamaya" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trop de transferts de médias en attente ; réessaie dans un instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יותר מדי העברות מדיה ממתינות; נסו שוב בעוד רגע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "बहुत सारे मीडिया ट्रांसफ़र प्रतीक्षा में हैं; थोड़ी देर में फिर कोशिश करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terlalu banyak transfer media yang menunggu; coba lagi sebentar lagi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ci sono troppi trasferimenti di media in attesa; riprova tra poco" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "待機中のメディア転送が多すぎます。しばらくしてからもう一度お試しください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대기 중인 미디어 전송이 너무 많아요. 잠시 후 다시 시도해 주세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terlalu banyak pemindahan media sedang menunggu; cuba lagi sebentar nanti" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "धेरै मिडिया स्थानान्तरणहरू पर्खिरहेका छन्; केही बेरपछि फेरि प्रयास गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er wachten te veel mediaoverdrachten; probeer het zo weer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zbyt wiele transferów multimediów czeka w kolejce; spróbuj ponownie za chwilę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Há demasiadas transferências de multimédia em espera; tenta novamente daqui a pouco" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Há transferências de mídia demais aguardando; tente de novo em instantes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком много передач медиа в очереди; попробуй ещё раз чуть позже" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "För många medieöverföringar väntar; försök igen om en stund" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நிறைய மீடியா பரிமாற்றங்கள் காத்திருக்கின்றன; சிறிது நேரத்தில் மீண்டும் முயற்சிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มีการถ่ายโอนสื่อรอคิวมากเกินไป ลองใหม่อีกครั้งในอีกสักครู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekleyen çok fazla medya aktarımı var; birazdan tekrar dene" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Забагато передач медіа в черзі; спробуй ще раз трохи згодом" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بہت زیادہ میڈیا منتقلیاں انتظار میں ہیں؛ تھوڑی دیر بعد دوبارہ کوشش کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Có quá nhiều lượt truyền media đang chờ; thử lại sau chút nhé" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待中的媒体传输过多,请稍后再试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待中的媒體傳輸過多,請稍後再試" + } + } + } + }, + "content.delivery.reason.private_media_downgrade_blocked" : { + "comment" : "Failure reason when a peer that previously supported encrypted media appears to downgrade", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الوسائط المشفّرة مطلوبة؛ اطلب من جهة الاتصال هذه التحديث" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এনক্রিপ্ট করা মিডিয়া দরকার; এই কন্টাক্টকে আপডেট করতে বলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verschlüsselte Medien erforderlich; bitte diesen Kontakt um ein Update" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encrypted media required; ask this contact to upgrade" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se requiere multimedia cifrada; pide a este contacto que actualice" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "رسانهٔ رمزگذاری‌شده لازم است؛ از این مخاطب بخواه به‌روزرسانی کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kailangan ng naka-encrypt na media; pakisabihan ang contact na ito na mag-update" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Médias chiffrés requis ; demande à ce contact de mettre à jour" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נדרשת מדיה מוצפנת; יש לבקש מאיש הקשר הזה לעדכן" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एन्क्रिप्टेड मीडिया ज़रूरी है; इस संपर्क से अपडेट करने के लिए कहें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media terenkripsi diperlukan; minta kontak ini memperbarui aplikasinya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sono richiesti media crittografati; chiedi a questo contatto di aggiornare" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "暗号化されたメディアが必要です。この連絡先にアップデートを頼んでください" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화된 미디어가 필요해요. 이 연락처에게 업데이트를 요청해 보세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Media tersulit diperlukan; minta kenalan ini mengemas kini aplikasinya" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "इन्क्रिप्ट गरिएको मिडिया आवश्यक छ; यो सम्पर्कलाई अपडेट गर्न भन्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versleutelde media vereist; vraag dit contact om te updaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymagane są zaszyfrowane multimedia; poproś ten kontakt o aktualizację" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "É necessária multimédia encriptada; pede a este contacto para atualizar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mídia criptografada é obrigatória; peça para este contato atualizar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуются зашифрованные медиа; попроси этот контакт обновить приложение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krypterade media krävs; be den här kontakten att uppdatera" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "என்க்ரிப்ட் செய்யப்பட்ட மீடியா தேவை; இந்தத் தொடர்பைப் புதுப்பிக்கச் சொல்லுங்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ต้องใช้สื่อที่เข้ารหัส ลองขอให้ผู้ติดต่อรายนี้อัปเดตแอป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Şifreli medya gerekli; bu kişiden güncellemesini iste" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Потрібні зашифровані медіа; попроси цей контакт оновити застосунок" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "انکرپٹڈ میڈیا درکار ہے؛ اس رابطے سے اپ ڈیٹ کرنے کو کہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cần media được mã hóa; hãy bảo liên hệ này cập nhật ứng dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要加密媒体;请让这位联系人升级客户端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "需要加密媒體;請讓這位聯絡人升級用戶端" + } + } + } + }, + "content.delivery.reason.private_media_too_many_fragments" : { + "comment" : "Failure reason when private media exceeds the Android-compatible fragment limit", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الملف كبير جدًا على تطبيق جهة الاتصال هذه (أكثر من 256 جزءًا عبر الشبكة المتداخلة)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই কন্টাক্টের ক্লায়েন্টের জন্য ফাইলটি খুব বড় (256টির বেশি মেশ ফ্র্যাগমেন্ট)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datei ist zu groß für den Client dieses Kontakts (mehr als 256 Mesh-Fragmente)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File is too large for this contact's client (more than 256 mesh fragments)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El archivo es demasiado grande para el cliente de este contacto (más de 256 fragmentos de malla)" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فایل برای کلاینت این مخاطب خیلی بزرگ است (بیش از 256 قطعهٔ مش)" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masyadong malaki ang file para sa client ng contact na ito (mahigit 256 mesh fragment)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le fichier est trop volumineux pour le client de ce contact (plus de 256 fragments mesh)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הקובץ גדול מדי עבור הקליינט של איש הקשר הזה (יותר מ-256 מקטעי mesh)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह फ़ाइल इस संपर्क के क्लाइंट के लिए बहुत बड़ी है (256 से ज़्यादा मेश फ़्रैगमेंट)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "File terlalu besar untuk klien kontak ini (lebih dari 256 fragmen mesh)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il file è troppo grande per il client di questo contatto (più di 256 frammenti mesh)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このファイルはこの連絡先のクライアントには大きすぎます(メッシュフラグメントが256個を超えています)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연락처의 클라이언트에는 파일이 너무 커요(메시 조각 256개 초과)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fail terlalu besar untuk klien kenalan ini (lebih daripada 256 fragmen mesh)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो सम्पर्कको क्लाइन्टका लागि फाइल धेरै ठूलो छ (256 भन्दा बढी मेश खण्डहरू)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het bestand is te groot voor de client van dit contact (meer dan 256 mesh-fragmenten)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plik jest za duży dla klienta tego kontaktu (ponad 256 fragmentów mesh)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "O ficheiro é demasiado grande para o cliente deste contacto (mais de 256 fragmentos da malha)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O arquivo é grande demais para o cliente deste contato (mais de 256 fragmentos da malha)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл слишком большой для клиента этого контакта (больше 256 фрагментов mesh-сети)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filen är för stor för den här kontaktens klient (fler än 256 mesh-fragment)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்தத் தொடர்பின் கிளையண்டுக்கு இந்தக் கோப்பு மிகப் பெரியது (256-க்கும் மேற்பட்ட மெஷ் துண்டுகள்)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไฟล์ใหญ่เกินไปสำหรับไคลเอ็นต์ของผู้ติดต่อรายนี้ (เกิน 256 ส่วนย่อยของ mesh)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosya bu kişinin istemcisi için çok büyük (256'dan fazla mesh parçası)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Файл завеликий для клієнта цього контакту (понад 256 фрагментів mesh-мережі)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ فائل اس رابطے کے کلائنٹ کے لیے بہت بڑی ہے (256 سے زیادہ میش ٹکڑے)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp quá lớn so với ứng dụng của liên hệ này (hơn 256 phân mảnh mesh)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件太大,这位联系人的客户端无法接收(超过 256 个网状网络分片)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檔案太大,這位聯絡人的用戶端無法接收(超過 256 個網狀網路分段)" + } + } + } + }, + "content.private_media.legacy_warning.message" : { + "comment" : "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تطبيق %@ لا يعلن عن دعم الوسائط الخاصة المشفّرة. سيتم توقيع هذا الملف لكنه لن يُشفَّر تشفيرًا تامًا بين الطرفين، لذا يمكن لمرحّلات الشبكة المتداخلة رؤيته. هل تريد إرسال الملف على أي حال؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর ক্লায়েন্ট এনক্রিপ্ট করা প্রাইভেট মিডিয়া সমর্থনের কথা জানায় না। এই ফাইলটি সাইন করা হবে কিন্তু এন্ড-টু-এন্ড এনক্রিপ্ট হবে না, তাই মেশ রিলেগুলো এটি দেখতে পারবে। তবুও ফাইলটি পাঠাবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Client von %@ gibt keine Unterstützung für verschlüsselte private Medien an. Diese Datei wird signiert, aber nicht Ende-zu-Ende-verschlüsselt – Mesh-Relays können sie also sehen. Datei trotzdem senden?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El cliente de %@ no anuncia compatibilidad con multimedia privada cifrada. Este archivo se firmará, pero no se cifrará de extremo a extremo, así que los relés de la malla podrán verlo. ¿Enviar el archivo de todos modos?" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کلاینت %@ پشتیبانی از رسانهٔ خصوصی رمزگذاری‌شده را اعلام نمی‌کند. این فایل امضا می‌شود اما رمزگذاری سرتاسری نخواهد داشت، بنابراین رله‌های شبکهٔ مش می‌توانند آن را ببینند. با این حال فایل ارسال شود؟" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hindi ina-advertise ng client ni %@ ang naka-encrypt na pribadong media. Mapipirmahan ang file na ito pero hindi ito magiging end-to-end encrypted, kaya makikita ito ng mga mesh relay. Ipadala pa rin ang file na ito?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le client de %@ n'annonce pas la prise en charge des médias privés chiffrés. Ce fichier sera signé mais pas chiffré de bout en bout : les relais du mesh pourront donc le voir. Envoyer quand même ce fichier ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הקליינט של %@ לא מציין תמיכה במדיה פרטית מוצפנת. הקובץ ייחתם אך לא יוצפן מקצה לקצה, כך שממסרי רשת ה-mesh יוכלו לראות אותו. לשלוח את הקובץ בכל זאת?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ का क्लाइंट एन्क्रिप्टेड निजी मीडिया का समर्थन नहीं बताता। यह फ़ाइल साइन की जाएगी लेकिन एंड-टू-एंड एन्क्रिप्ट नहीं होगी, इसलिए मेश रिले इसे देख सकते हैं। फिर भी यह फ़ाइल भेजें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klien %@ tidak menyatakan dukungan untuk media privat terenkripsi. File ini akan ditandatangani tetapi tidak dienkripsi end-to-end, jadi relai mesh bisa melihatnya. Tetap kirim file ini?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il client di %@ non annuncia il supporto ai media privati crittografati. Questo file sarà firmato ma non crittografato end-to-end, quindi i relay della mesh potranno vederlo. Inviare comunque il file?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@のクライアントは暗号化されたプライベートメディアへの対応を通知していません。このファイルは署名されますが、エンドツーエンド暗号化はされないため、メッシュ中継が内容を見ることができます。それでもこのファイルを送信しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@님의 클라이언트는 암호화된 개인 미디어 지원을 알리지 않아요. 이 파일은 서명은 되지만 종단간 암호화는 되지 않아 메시 릴레이가 내용을 볼 수 있어요. 그래도 이 파일을 보낼까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klien %@ tidak menyatakan sokongan untuk media peribadi tersulit. Fail ini akan ditandatangani tetapi tidak disulitkan hujung ke hujung, jadi geganti mesh boleh melihatnya. Hantar fail ini juga?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को क्लाइन्टले इन्क्रिप्ट गरिएको निजी मिडियाको समर्थन जनाउँदैन। यो फाइल साइन गरिनेछ तर एन्ड-टु-एन्ड इन्क्रिप्ट हुनेछैन, त्यसैले मेश रिलेहरूले यसलाई देख्न सक्छन्। जे होस्, यो फाइल पठाउने हो?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De client van %@ geeft niet aan versleutelde privémedia te ondersteunen. Dit bestand wordt ondertekend maar niet end-to-end versleuteld, dus mesh-relays kunnen het zien. Bestand toch verzenden?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Klient %@ nie zgłasza obsługi zaszyfrowanych prywatnych multimediów. Ten plik zostanie podpisany, ale nie zaszyfrowany end-to-end, więc przekaźniki mesh będą mogły go zobaczyć. Mimo to wysłać plik?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "O cliente de %@ não anuncia suporte para multimédia privada encriptada. Este ficheiro será assinado, mas não encriptado de ponta a ponta, pelo que os relays da malha podem vê-lo. Enviar o ficheiro mesmo assim?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O cliente de %@ não anuncia suporte a mídia privada criptografada. Este arquivo será assinado, mas não criptografado de ponta a ponta, então os relays da malha podem vê-lo. Enviar o arquivo mesmo assim?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Клиент %@ не заявляет поддержку зашифрованных личных медиа. Файл будет подписан, но не защищён сквозным шифрованием, поэтому ретрансляторы mesh-сети смогут его увидеть. Всё равно отправить файл?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@:s klient anger inte stöd för krypterade privata media. Filen signeras men totalsträckskrypteras inte, så mesh-reläer kan se den. Skicka filen ändå?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-இன் கிளையண்ட் என்க்ரிப்ட் செய்யப்பட்ட தனிப்பட்ட மீடியாவை ஆதரிப்பதாக அறிவிக்கவில்லை. இந்தக் கோப்பு கையொப்பமிடப்படும், ஆனால் எண்ட்-டு-எண்ட் என்க்ரிப்ட் செய்யப்படாது; எனவே மெஷ் ரிலேக்கள் இதைப் பார்க்க முடியும். இருந்தாலும் இந்தக் கோப்பை அனுப்பவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไคลเอ็นต์ของ %@ ไม่ได้แจ้งว่ารองรับสื่อส่วนตัวแบบเข้ารหัส ไฟล์นี้จะถูกเซ็นกำกับแต่ไม่ได้เข้ารหัสแบบต้นทางถึงปลายทาง รีเลย์ในเครือข่าย mesh จึงมองเห็นได้ ยังจะส่งไฟล์นี้อยู่ไหม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ adlı kişinin istemcisi şifreli özel medya desteği bildirmiyor. Bu dosya imzalanacak ama uçtan uca şifrelenmeyecek, yani mesh aktarıcıları dosyayı görebilir. Dosya yine de gönderilsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Клієнт %@ не заявляє підтримку зашифрованих приватних медіа. Файл буде підписано, але не зашифровано наскрізно, тож ретранслятори mesh-мережі зможуть його бачити. Все одно надіслати файл?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کا کلائنٹ انکرپٹڈ نجی میڈیا کی حمایت ظاہر نہیں کرتا۔ یہ فائل سائن ہوگی لیکن اینڈ ٹو اینڈ انکرپٹ نہیں ہوگی، اس لیے میش ریلے اسے دیکھ سکتے ہیں۔ پھر بھی یہ فائل بھیجیں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ứng dụng của %@ không báo hỗ trợ media riêng tư được mã hóa. Tệp này sẽ được ký nhưng không được mã hóa đầu cuối, nên các thiết bị chuyển tiếp trong mạng mesh có thể xem được. Vẫn gửi tệp này chứ?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 的客户端未声明支持加密私密媒体。此文件会经过签名,但不会端到端加密,因此网状中继可以看到内容。仍要发送此文件吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 的用戶端未聲明支援加密私人媒體。這個檔案會經過簽署,但不會端對端加密,因此網狀中繼可以看到內容。仍要傳送這個檔案嗎?" + } + } + } + }, + "content.private_media.legacy_warning.send" : { + "comment" : "Destructive confirmation action for one legacy clear private-media send", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إرسال ملف مكشوف" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "দৃশ্যমান ফাইল পাঠান" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "sichtbare datei senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "send visible file" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar archivo visible" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ارسال فایل قابل‌مشاهده" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "ipadala ang nakikitang file" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "envoyer le fichier visible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שליחת קובץ גלוי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दिखने वाली फ़ाइल भेजें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kirim file terlihat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "invia file visibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "見えるファイルを送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보이는 파일 보내기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hantar fail boleh dilihat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "देखिने फाइल पठाउनुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zichtbaar bestand verzenden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wyślij widoczny plik" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar ficheiro visível" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "enviar arquivo visível" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "отправить видимый файл" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skicka synlig fil" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பார்க்கக்கூடிய கோப்பை அனுப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งไฟล์แบบมองเห็นได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "görünür dosyayı gönder" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надіслати видимий файл" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "دکھائی دینے والی فائل بھیجیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "gửi tệp xem được" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送可见文件" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "傳送可見檔案" + } + } + } + }, + "content.private_media.legacy_warning.title" : { + "comment" : "Title warning before sending private media to an older client in a clear signed envelope", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإرسال بدون التشفير التام بين الطرفين؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এন্ড-টু-এন্ড এনক্রিপশন ছাড়া পাঠাবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Ende-zu-Ende-Verschlüsselung senden?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send without end-to-end encryption?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Enviar sin cifrado de extremo a extremo?" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدون رمزگذاری سرتاسری ارسال شود؟" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ipadala nang walang end-to-end encryption?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer sans chiffrement de bout en bout ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לשלוח ללא הצפנה מקצה לקצה?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एंड-टू-एंड एन्क्रिप्शन के बिना भेजें?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kirim tanpa enkripsi end-to-end?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inviare senza crittografia end-to-end?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エンドツーエンド暗号化なしで送信しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종단간 암호화 없이 보낼까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hantar tanpa penyulitan hujung ke hujung?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एन्ड-टु-एन्ड इन्क्रिप्शनविना पठाउने हो?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verzenden zonder end-to-end-versleuteling?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysłać bez szyfrowania end-to-end?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar sem encriptação de ponta a ponta?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar sem criptografia de ponta a ponta?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить без сквозного шифрования?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skicka utan totalsträckskryptering?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எண்ட்-டு-எண்ட் என்க்ரிப்ஷன் இல்லாமல் அனுப்பவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งโดยไม่มีการเข้ารหัสแบบต้นทางถึงปลายทางไหม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uçtan uca şifreleme olmadan gönderilsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Надіслати без наскрізного шифрування?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اینڈ ٹو اینڈ انکرپشن کے بغیر بھیجیں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gửi mà không mã hóa đầu cuối?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在没有端到端加密的情况下发送?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "要在沒有端對端加密的情況下傳送嗎?" + } + } + } + }, + "content.accessibility.carrying_mail" : { + "comment" : "Accessibility label for the courier mail indicator", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحمل %lld من الرسائل المختومة للأصدقاء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বন্ধুদের জন্য %lldটি সিল করা মেসেজ বহন করা হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du trägst %lld versiegelte Nachrichten für Freunde" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carrying %lld sealed messages for friends" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Llevas %lld mensajes sellados para amigos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال حمل %lld پیام مهروموم‌شده برای دوستان" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "May dalang %lld selyadong mensahe para sa mga kaibigan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu transportes %lld messages scellés pour des amis" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נושא %lld הודעות חתומות עבור חברים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दोस्तों के लिए %lld सीलबंद संदेश साथ ले जा रहे हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membawa %lld pesan tersegel untuk teman-teman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stai trasportando %lld messaggi sigillati per amici" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "友達のために%lld件の封印されたメッセージを運んでいます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "친구를 위해 봉인된 메시지 %lld개를 운반 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membawa %lld mesej termeterai untuk rakan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "साथीहरूका लागि %lld सिलबन्दी सन्देश बोकिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je draagt %lld verzegelde berichten mee voor vrienden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przenosisz %lld zapieczętowanych wiadomości dla znajomych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A transportar %lld mensagens seladas para amigos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Levando %lld mensagens lacradas para amigos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы несёте %lld запечатанных сообщений для друзей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bär %lld förseglade meddelanden åt vänner" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நண்பர்களுக்காக %lld முத்திரையிடப்பட்ட செய்திகளை எடுத்துச் செல்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังถือข้อความปิดผนึก %lld ข้อความไว้ให้เพื่อน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arkadaşlar için %lld mühürlü mesaj taşınıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переноситься %lld запечатаних повідомлень для друзів" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "دوستوں کے لیے %lld مہربند پیغامات لے جا رہے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang mang %lld tin nhắn niêm phong cho bạn bè" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在为朋友携带 %lld 条密封消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在為朋友攜帶 %lld 則密封訊息" + } + } + } + }, + "content.delivery.carried" : { + "comment" : "Delivery status description for messages handed to a courier for physical delivery", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يحملها صديق قد يلتقي به" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এমন এক বন্ধু বহন করছে, যার সাথে তার দেখা হতে পারে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird von einem Freund getragen, der sie vielleicht trifft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carried by a friend who may meet them" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lo lleva un amigo que podría encontrarse con esa persona" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دوستی آن را حمل می‌کند که شاید او را ببیند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dala ng isang kaibigan na baka makasalubong sila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transporté par un ami qui pourrait croiser cette personne" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בדרך עם חבר שאולי יפגוש אותם" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक दोस्त इसे ले जा रहा है जो शायद उनसे मिले" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dibawa oleh teman yang mungkin akan bertemu dengannya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trasportato da un amico che potrebbe incontrarli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "相手に会うかもしれない友達が運んでいます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상대를 만날 수도 있는 친구가 전달 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dibawa oleh rakan yang mungkin bertemu dengan mereka" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "उनीहरूलाई भेट्न सक्ने साथीले बोकिरहेका छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wordt meegenomen door een vriend die hen misschien tegenkomt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przenosi ją znajomy, który może spotkać odbiorcę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transportada por um amigo que talvez encontre o destinatário" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Levada por um amigo que talvez encontre o destinatário" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несёт друг, который может встретить получателя" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bärs av en vän som kanske träffar dem" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அவர்களைச் சந்திக்கக்கூடிய நண்பர் எடுத்துச் செல்கிறார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มีเพื่อนที่อาจได้เจอพวกเขาช่วยถือไปให้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onlarla karşılaşabilecek bir arkadaş taşıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Несе друг, який може їх зустріти" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایسا دوست لے جا رہا ہے جو ان سے مل سکتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Một người bạn có thể gặp họ đang mang hộ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由可能遇见他们的朋友捎带" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "由可能遇見他們的朋友代為攜帶" + } + } + } + }, + "content.delivery.not_sent_yet" : { + "comment" : "Delivery status description for a message that has not entered any send pipeline", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم تُرسل بعد" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখনও পাঠানো হয়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch nicht gesendet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not sent yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún no se ha enviado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنوز ارسال نشده" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hindi pa naipapadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas encore envoyé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עדיין לא נשלחה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी भेजा नहीं गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belum terkirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non ancora inviato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "未送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 전송되지 않음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belum dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिलेसम्म पठाइएको छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nog niet verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jeszcze nie wysłano" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda não enviada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda não enviada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ещё не отправлено" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inte skickat än" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்னும் அனுப்பப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่ได้ส่ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Henüz gönderilmedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ще не надіслано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی نہیں بھیجا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chưa gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未傳送" + } + } + } + }, + "content.delivery.reason.media_encoding_failed" : { + "comment" : "Failure reason when private media cannot be encoded", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر تجهيز الوسائط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া প্রস্তুত করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medien konnten nicht vorbereitet werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to prepare media" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo preparar el contenido multimedia" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آماده‌سازی رسانه ناموفق بود" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hindi naihanda ang media" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de préparer le média" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הכנת המדיה נכשלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया तैयार नहीं हो सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal menyiapkan media" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile preparare il contenuto multimediale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアの準備に失敗しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어를 준비하지 못했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal menyediakan media" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया तयार गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorbereiden van media mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się przygotować multimediów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao preparar o conteúdo multimédia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao preparar a mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось подготовить медиафайл" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunde inte förbereda media" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியாவைத் தயார் செய்ய முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เตรียมสื่อไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya hazırlanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося підготувати медіа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا تیار نہیں ہو سکا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể chuẩn bị media" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法准备媒体" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法準備媒體" + } + } + } + }, + "content.delivery.reason.media_signing_failed" : { + "comment" : "Failure reason when a legacy private-media packet cannot be signed", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر توثيق الوسائط" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিডিয়া যাচাই করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medien konnten nicht authentifiziert werden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to authenticate media" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo autenticar el contenido multimedia" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأیید اصالت رسانه ناموفق بود" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hindi na-authenticate ang media" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'authentifier le média" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אימות המדיה נכשל" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मीडिया प्रमाणित नहीं हो सका" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal mengautentikasi media" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile autenticare il contenuto multimediale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メディアの認証に失敗しました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미디어를 인증하지 못했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gagal mengesahkan media" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिडिया प्रमाणित गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifiëren van media mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się uwierzytelnić multimediów" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao autenticar o conteúdo multimédia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha ao autenticar a mídia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось подтвердить подлинность медиафайла" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunde inte autentisera media" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மீடியாவை உறுதிப்படுத்த முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยืนยันความถูกต้องของสื่อไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medya doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не вдалося підтвердити справжність медіа" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "میڈیا کی تصدیق نہیں ہو سکی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể xác thực media" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法验证媒体" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法驗證媒體" + } + } + } + }, + "content.header.carrying_mail" : { + "comment" : "Tooltip for the courier mail indicator", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحمل رسائل مختومة لتوصيلها نيابةً عن الأصدقاء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বন্ধুদের হয়ে পৌঁছে দেওয়ার জন্য সিল করা মেসেজ বহন করা হচ্ছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du trägst versiegelte Nachrichten, um sie für Freunde zuzustellen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carrying sealed messages for friends to deliver" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Llevas mensajes sellados de amigos para entregarlos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال حمل پیام‌های مهروموم‌شده برای تحویل به‌جای دوستان" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "May dalang mga selyadong mensahe na ihahatid para sa mga kaibigan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu transportes des messages scellés à remettre pour des amis" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נושא הודעות חתומות למסירה עבור חברים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दोस्तों की ओर से पहुँचाने के लिए सीलबंद संदेश साथ ले जा रहे हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membawa pesan tersegel milik teman untuk diantarkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stai trasportando messaggi sigillati da consegnare agli amici" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "友達に届けるために封印されたメッセージを運んでいます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "친구에게 전달할 봉인된 메시지를 운반 중" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Membawa mesej termeterai untuk dihantar kepada rakan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "साथीहरूलाई पुर्‍याउन सिलबन्दी सन्देशहरू बोकिँदै" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je draagt verzegelde berichten mee om voor vrienden te bezorgen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przenosisz zapieczętowane wiadomości, by doręczyć je znajomym" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "A transportar mensagens seladas para entregar aos amigos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Levando mensagens lacradas para entregar aos amigos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы несёте запечатанные сообщения для доставки друзьям" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bär förseglade meddelanden åt vänner för leverans" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நண்பர்களுக்காக முத்திரையிடப்பட்ட செய்திகளை வழங்க எடுத்துச் செல்கிறது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังถือข้อความปิดผนึกไว้เพื่อนำไปส่งให้เพื่อน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arkadaşlara ulaştırılmak üzere mühürlü mesajlar taşınıyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переносяться запечатані повідомлення для доставки друзям" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "دوستوں تک پہنچانے کے لیے مہربند پیغامات ساتھ ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang mang tin nhắn niêm phong để chuyển cho bạn bè" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在携带要送给朋友的密封消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在攜帶要送給朋友的密封訊息" + } + } + } + }, "content.banner.bluetooth_denied" : { "comment" : "Persistent banner while Bluetooth permission is denied; tapping opens system settings", "extractionState" : "manual", @@ -52475,6 +57125,192 @@ } } }, + "location_channels.row_title_unknown" : { + "comment" : "List row title for a channel whose participant count is unknown for privacy; placeholder is the place label", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [؟ أشخاص]" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? জন]" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? leute]" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? people]" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? personas]" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [؟ نفر]" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? tao]" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? personnes]" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? אנשים]" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? लोग]" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? orang]" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? persone]" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? 人]" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? 명]" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? orang]" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? जना]" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? mensen]" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? osób]" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? pessoas]" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? pessoas]" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? человек]" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? personer]" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? பேர்]" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? คน]" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? kişi]" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? людей]" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? لوگ]" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? người]" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? 人]" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ [? 人]" + } + } + } + }, "location_channels.accessibility.add_bookmark" : { "comment" : "Accessibility action name for bookmarking a channel", "extractionState" : "manual", diff --git a/bitchatTests/LocalizationCoverageTests.swift b/bitchatTests/LocalizationCoverageTests.swift index c9d7c602..8748b960 100644 --- a/bitchatTests/LocalizationCoverageTests.swift +++ b/bitchatTests/LocalizationCoverageTests.swift @@ -69,4 +69,45 @@ struct LocalizationCoverageTests { let missing = main.allLocales.subtracting(shareExt.allLocales).sorted() #expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))") } + + /// The catalog tests above validate the CATALOG; this one validates the + /// CODE. A `String(localized:)` whose key is absent from every catalog + /// compiles and runs fine — it just silently ships its English + /// `defaultValue` to all 29 non-source locales. That blind spot let the + /// entire notices/board composer (10 keys), two delivery states, and two + /// media-failure reasons go untranslated while the coverage tests stayed + /// green. Interpolated keys can't be checked statically and are skipped; + /// every literal key must resolve. + @Test func everyCodeReferencedKeyExistsInACatalog() throws { + let main = try Self.loadCatalog("bitchat/Localizable.xcstrings") + let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings") + let knownKeys = Set(main.coverage.keys).union(shareExt.coverage.keys) + + let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#) + var missing: [String] = [] + + for sourceRoot in ["bitchat", "bitchatShareExtension"] { + let rootURL = Self.repoRoot.appendingPathComponent(sourceRoot) + let enumerator = try #require(FileManager.default.enumerator( + at: rootURL, + includingPropertiesForKeys: nil + )) + for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" { + let source = try String(contentsOf: fileURL, encoding: .utf8) + let range = NSRange(source.startIndex..., in: source) + pattern.enumerateMatches(in: source, range: range) { match, _, _ in + guard let match, let keyRange = Range(match.range(at: 1), in: source) else { return } + let key = String(source[keyRange]) + if !knownKeys.contains(key) { + missing.append("\(key) (\(fileURL.lastPathComponent))") + } + } + } + } + + #expect( + missing.isEmpty, + "keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))" + ) + } } From 5c4b814c5658dd35b50f1ba2a27a17648aa854c8 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:21:02 +0200 Subject: [PATCH 13/14] Localize CommandProcessor: all 48 command strings, 30 locales (#1657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 9114 ++++++++++++++++++++++ bitchat/Services/CommandProcessor.swift | 133 +- bitchatTests/CommandProcessorTests.swift | 4 +- 3 files changed, 9180 insertions(+), 71 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 7a223545..f7002d0c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -4279,6 +4279,9120 @@ } } }, + "command.action.not_found" : { + "comment" : "Error when an action command can't resolve its target; placeholders are the command and the nickname", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر %1$@ %2$@: غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@-কে %1$@ করা গেল না: পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ geht nicht: nicht gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot %1$@ %2$@: not found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede %1$@ a %2$@: no encontrado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نمی‌توان %2$@ را %1$@ کرد: پیدا نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi ma-%1$@ si %2$@: hindi nahanap" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de %1$@ %2$@ : introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אי אפשר לעשות %1$@ ל-%2$@: לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ को %1$@ नहीं कर सकते: नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa %1$@ %2$@: tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile %1$@ %2$@: non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ を %1$@ できない: 見つからない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@에게 %1$@ 할 수 없어요: 찾을 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat %1$@ %2$@: tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ लाई %1$@ गर्न सकिएन: फेला परेन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan %2$@ niet %1$@: niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można %1$@ %2$@: nie znaleziono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível %1$@ %2$@: não encontrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não dá para %1$@ %2$@: não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не получается %1$@ %2$@: не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan inte %1$@ %2$@: hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ ஐ %1$@ செய்ய முடியாது: கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่สามารถ %1$@ %2$@ ได้: ไม่พบ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ için %1$@ yapılamıyor: bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося %1$@ %2$@: не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ کو %1$@ نہیں کر سکتے: نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể %1$@ %2$@: không tìm thấy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法 %1$@ %2$@:未找到" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法 %1$@ %2$@:找不到" + } + } + } + }, + "command.action.not_found_mesh" : { + "comment" : "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر %1$@ %2$@: غير موجود على شبكة mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@-কে %1$@ করা গেল না: mesh-এ পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ geht nicht: nicht im mesh gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot %1$@ %2$@: not found on mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede %1$@ a %2$@: no encontrado en la mesh" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نمی‌توان %2$@ را %1$@ کرد: در mesh پیدا نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi ma-%1$@ si %2$@: hindi nahanap sa mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de %1$@ %2$@ : introuvable sur le mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אי אפשר לעשות %1$@ ל-%2$@: לא נמצא ב-mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ को %1$@ नहीं कर सकते: mesh पर नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa %1$@ %2$@: tidak ditemukan di mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile %1$@ %2$@: non trovato sulla mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ を %1$@ できない: メッシュ上に見つからない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@에게 %1$@ 할 수 없어요: 메시에서 찾을 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat %1$@ %2$@: tidak dijumpai di mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ लाई %1$@ गर्न सकिएन: मेसमा फेला परेन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan %2$@ niet %1$@: niet gevonden op mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można %1$@ %2$@: nie znaleziono w mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível %1$@ %2$@: não encontrado na mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não dá para %1$@ %2$@: não encontrado na mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не получается %1$@ %2$@: не найден в mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan inte %1$@ %2$@: hittades inte på meshen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ ஐ %1$@ செய்ய முடியாது: mesh இல் கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่สามารถ %1$@ %2$@ ได้: ไม่พบใน mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ için %1$@ yapılamıyor: mesh'te bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося %1$@ %2$@: не знайдено в mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ کو %1$@ نہیں کر سکتے: mesh پر نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể %1$@ %2$@: không tìm thấy trên mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法 %1$@ %2$@:在 mesh 上未找到" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法 %1$@ %2$@:在 mesh 上找不到" + } + } + } + }, + "command.action.usage" : { + "comment" : "Usage hint for a command that takes a nickname; placeholder is the command name", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /%@ " + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /%@ <ডাকনাম>" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /%@ " + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /%@ " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /%@ " + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /%@ " + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /%@ " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /%@ " + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /%@ " + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /%@ <उपनाम>" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /%@ " + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /%@ " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /%@ <ニックネーム>" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /%@ <닉네임>" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /%@ " + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /%@ <उपनाम>" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /%@ " + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /%@ " + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /%@ " + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /%@ " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /%@ <ник>" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /%@ " + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /%@ <புனைப்பெயர்>" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /%@ <ชื่อเล่น>" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /%@ " + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /%@ <нікнейм>" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /%@ <عرفی نام>" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /%@ " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/%@ <昵称>" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/%@ <暱稱>" + } + } + } + }, + "command.block.already" : { + "comment" : "Reply when /block targets an already-blocked nickname", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ محظور بالفعل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ আগেই ব্লক করা আছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ist bereits blockiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already blocked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ya está bloqueado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ از قبل مسدود است" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-block na si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ est déjà bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ כבר חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पहले से ब्लॉक है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sudah diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ è già bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ はすでにブロック済み" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@은(는) 이미 차단되어 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sudah disekat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पहिले नै ब्लक गरिएको छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is al geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ znajduje się już na liście zablokowanych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ já está bloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ já está bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ уже заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ är redan blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ஏற்கனவே தடுக்கப்பட்டுள்ளார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ถูกบล็อกอยู่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ zaten engelli" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ вже заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ پہلے سے بلاک ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ đã bị chặn rồi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 已被屏蔽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 已被封鎖" + } + } + } + }, + "command.block.done" : { + "comment" : "Confirmation after blocking a mesh peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم حظر %@. لن ترى رسائل هذا الشخص بعد الآن" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে ব্লক করা হলো। এই ব্যক্তির বার্তা আর দেখতে পাবে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ blockiert. du siehst die nachrichten dieser person nicht mehr" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@. you will no longer see their messages" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado. ya no verás sus mensajes" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ مسدود شد. دیگر پیام‌های این شخص را نمی‌بینی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "na-block si %@. hindi mo na makikita ang mga mensahe niya" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqué. tu ne verras plus ses messages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נחסם. ההודעות של האדם הזה לא יוצגו יותר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ब्लॉक किया गया। अब तुम्हें इस व्यक्ति के संदेश नहीं दिखेंगे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ diblokir. kamu tidak akan melihat pesannya lagi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloccato. non vedrai più i suoi messaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ をブロックした。相手のメッセージはもう表示されない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 차단했어요. 이제 그 사람의 메시지가 보이지 않아요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ disekat. mesejnya tidak akan kelihatan lagi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ब्लक गरियो। अब यस व्यक्तिका सन्देशहरू देखिने छैनन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ geblokkeerd. je ziet berichten van deze persoon niet meer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zablokowano %@. nie zobaczysz już wiadomości od tej osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado. deixas de ver as mensagens dessa pessoa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado. você não vai mais ver as mensagens dessa pessoa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ заблокирован. сообщения этого человека больше не показываются" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockerade %@. du ser inte längre deras meddelanden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ தடுக்கப்பட்டார். இனி அவர்களின் செய்திகளை நீங்கள் பார்க்க மாட்டீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บล็อก %@ แล้ว คุณจะไม่เห็นข้อความของเขาอีก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ engellendi. artık mesajlarını görmeyeceksin" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ заблоковано. ти більше не бачитимеш повідомлень від них" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو بلاک کر دیا گیا۔ اب آپ کو ان کے پیغامات نظر نہیں آئیں گے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã chặn %@. bạn sẽ không thấy tin nhắn của họ nữa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已屏蔽 %@,你不会再看到对方的消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已封鎖 %@,你不會再看到對方的訊息" + } + } + } + }, + "command.block.done_geo" : { + "comment" : "Confirmation after blocking a geohash participant", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم حظر %@ في محادثات geohash" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash চ্যাটে %@-কে ব্লক করা হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ in geohash-chats blockiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked %@ in geohash chats" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado en los chats de geohash" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ در چت‌های geohash مسدود شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "na-block si %@ sa mga geohash chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqué dans les chats geohash" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נחסם בצ'אטים של geohash" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash चैट में %@ को ब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ diblokir di chat geohash" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloccato nelle chat geohash" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash チャットで %@ をブロックした" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash 채팅에서 %@을(를) 차단했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ disekat dalam sembang geohash" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash च्याटहरूमा %@ ब्लक गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ geblokkeerd in geohash-chats" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zablokowano %@ w czatach geohash" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado nas conversas geohash" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bloqueado nos chats geohash" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ заблокирован в geohash-чатах" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockerade %@ i geohash-chattar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash அரட்டைகளில் %@ தடுக்கப்பட்டார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บล็อก %@ ในแชท geohash แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ geohash sohbetlerinde engellendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ заблоковано в geohash-чатах" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash چیٹس میں %@ کو بلاک کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã chặn %@ trong các kênh trò chuyện geohash" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已在 geohash 聊天中屏蔽 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已在 geohash 聊天中封鎖 %@" + } + } + } + }, + "command.block.failed" : { + "comment" : "Error when /block can't resolve or verify the target", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر حظر %@: غير موجود أو تعذّر التحقق من الهوية" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে ব্লক করা গেল না: পাওয়া যায়নি বা পরিচয় যাচাই করা যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kann %@ nicht blockieren: nicht gefunden oder identität nicht überprüfbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot block %@: not found or unable to verify identity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede bloquear a %@: no encontrado o no se pudo verificar la identidad" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نمی‌توان %@ را مسدود کرد: پیدا نشد یا هویتش قابل تأیید نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi ma-block si %@: hindi nahanap o hindi ma-verify ang identity" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de bloquer %@ : introuvable ou identité invérifiable" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אי אפשר לחסום את %@: לא נמצא או שאי אפשר לאמת את הזהות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को ब्लॉक नहीं कर सकते: नहीं मिला या पहचान सत्यापित नहीं हो सकी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa memblokir %@: tidak ditemukan atau identitas tidak bisa diverifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile bloccare %@: non trovato o identità non verificabile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ をブロックできない: 見つからないか身元を確認できない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 차단할 수 없어요: 찾을 수 없거나 신원을 확인할 수 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat menyekat %@: tidak dijumpai atau identiti tidak dapat disahkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ लाई ब्लक गर्न सकिएन: फेला परेन वा पहिचान प्रमाणित गर्न सकिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan %@ niet blokkeren: niet gevonden of identiteit niet te verifiëren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można zablokować %@: nie znaleziono lub nie udało się zweryfikować tożsamości" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível bloquear %@: não encontrado ou identidade impossível de verificar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não dá para bloquear %@: não encontrado ou não foi possível verificar a identidade" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не получается заблокировать %@: не найден или не удалось проверить личность" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan inte blockera %@: hittades inte eller kunde inte verifiera identiteten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ஐ தடுக்க முடியாது: கிடைக்கவில்லை அல்லது அடையாளத்தை சரிபார்க்க முடியவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บล็อก %@ ไม่ได้: ไม่พบหรือยืนยันตัวตนไม่ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ engellenemiyor: bulunamadı veya kimliği doğrulanamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося заблокувати %@: не знайдено або неможливо перевірити особу" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو بلاک نہیں کیا جا سکا: نہیں ملا یا شناخت کی تصدیق ممکن نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể chặn %@: không tìm thấy hoặc không xác minh được danh tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法屏蔽 %@:未找到或无法验证身份" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法封鎖 %@:找不到或無法驗證身分" + } + } + } + }, + "command.block.list" : { + "comment" : "Reply to /block with no argument; placeholders are the mesh and geohash block lists", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأقران المحظورون: %1$@ | حظر geohash: %2$@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্লক করা পিয়ার: %1$@ | geohash ব্লক: %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockierte peers: %1$@ | geohash-blockierungen: %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "blocked peers: %1$@ | geohash blocks: %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "peers bloqueados: %1$@ | bloqueos de geohash: %2$@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "همتاهای مسدود: %1$@ | مسدودی‌های geohash: %2$@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga naka-block na peer: %1$@ | mga geohash block: %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pairs bloqués : %1$@ | blocages geohash : %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עמיתים חסומים: %1$@ | חסימות geohash: %2$@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लॉक किए गए पीयर: %1$@ | geohash ब्लॉक: %2$@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer yang diblokir: %1$@ | blokir geohash: %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer bloccati: %1$@ | blocchi geohash: %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブロック中のピア: %1$@ | geohash のブロック: %2$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "차단한 피어: %1$@ | geohash 차단: %2$@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "rakan disekat: %1$@ | sekatan geohash: %2$@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्लक गरिएका पियरहरू: %1$@ | geohash ब्लकहरू: %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geblokkeerde peers: %1$@ | geohash-blokkades: %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zablokowane peery: %1$@ | blokady geohash: %2$@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "peers bloqueados: %1$@ | bloqueios geohash: %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "peers bloqueados: %1$@ | bloqueios geohash: %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "заблокированные пиры: %1$@ | блокировки geohash: %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockerade personer: %1$@ | geohash-blockeringar: %2$@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தடுக்கப்பட்ட நபர்கள்: %1$@ | geohash தடைகள்: %2$@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คนที่ถูกบล็อก: %1$@ | บล็อกใน geohash: %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "engellenen kişiler: %1$@ | geohash engelleri: %2$@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "заблоковані люди: %1$@ | блокування geohash: %2$@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "بلاک شدہ لوگ: %1$@ | geohash بلاکس: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "người bị chặn: %1$@ | chặn geohash: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已屏蔽的人:%1$@ | geohash 屏蔽:%2$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已封鎖的人:%1$@ | geohash 封鎖:%2$@" + } + } + } + }, + "command.drop.finding_place" : { + "comment" : "Error when /drop runs before a location fix arrives", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ما زلنا نحدد هذا المكان — حاول مجددًا بعد لحظات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই জায়গাটা এখনও খুঁজে পাচ্ছি — একটু পরে আবার চেষ্টা করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dieser ort wird noch gesucht — versuch es gleich nochmal" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "still finding this place — try again in a moment" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "todavía buscando este lugar — inténtalo de nuevo en un momento" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هنوز داریم این مکان را پیدا می‌کنیم — چند لحظه دیگر دوباره امتحان کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hinahanap pa ang lugar na ito — subukan ulit maya-maya" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "on cherche encore cet endroit — réessaie dans un instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עוד מאתרים את המקום הזה — נסו שוב עוד רגע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह जगह अभी ढूँढी जा रही है — थोड़ी देर में फिर कोशिश करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "masih mencari tempat ini — coba lagi sebentar lagi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sto ancora individuando questo posto — riprova tra un attimo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この場所をまだ特定中 — 少ししてからもう一度試して" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 이 장소를 찾는 중이에요 — 잠시 후 다시 시도해 주세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "masih mencari tempat ini — cuba lagi sebentar nanti" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो ठाउँ अझै पत्ता लगाउँदै — एकछिनमा फेरि प्रयास गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "deze plek wordt nog gezocht — probeer het zo nog eens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wciąż ustalam to miejsce — spróbuj ponownie za chwilę" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda a localizar este sítio — tenta outra vez daqui a pouco" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda localizando este lugar — tente de novo daqui a pouco" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "всё ещё определяю это место — попробуй снова чуть позже" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "letar fortfarande efter den här platsen — försök igen om en stund" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த இடத்தை இன்னும் கண்டறிகிறோம் — சிறிது நேரம் கழித்து மீண்டும் முயற்சிக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังหาตำแหน่งของที่นี่อยู่ — ลองใหม่อีกสักครู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu yer hâlâ tespit ediliyor — az sonra tekrar dene" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ще визначаємо це місце — спробуй за мить" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی اس جگہ کا پتہ لگایا جا رہا ہے — تھوڑی دیر میں دوبارہ کوشش کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vẫn đang xác định nơi này — thử lại sau một lát nhé" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还在定位这个地方 — 请稍后再试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還在定位這個地方 — 請稍後再試" + } + } + } + }, + "command.drop.left" : { + "comment" : "Confirmation after /drop pins a note", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 تُركت ملاحظة هنا — تتلاشى خلال 24 ساعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 এখানে নোট রাখা হলো — ২৪ ঘণ্টায় মিলিয়ে যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 notiz hier hinterlassen — sie verblasst in 24h" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 note left here — it fades in 24h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nota dejada aquí — se desvanece en 24h" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 یادداشت اینجا گذاشته شد — تا ۲۴ ساعت دیگر محو می‌شود" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nag-iwan ng note dito — maglalaho ito sa loob ng 24h" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 note laissée ici — elle s'efface dans 24h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 הושארה כאן הערה — היא תיעלם בעוד 24 שעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 यहाँ नोट छोड़ा गया — यह 24 घंटे में मिट जाएगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 catatan ditinggalkan di sini — akan hilang dalam 24 jam" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nota lasciata qui — svanisce in 24h" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 ここにノートを残した — 24時間で消える" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 여기에 노트를 남겼어요 — 24시간 후에 사라져요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nota ditinggalkan di sini — hilang dalam 24 jam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 यहाँ नोट छोडियो — 24 घण्टामा हराउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 notitie hier achtergelaten — verdwijnt na 24 uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 notka zostawiona tutaj — zniknie za 24 h" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nota deixada aqui — desaparece em 24h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 nota deixada aqui — some em 24h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 заметка оставлена здесь — исчезнет через 24 ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 anteckning lämnad här — den tonar bort om 24h" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 குறிப்பு இங்கே விடப்பட்டது — 24 மணி நேரத்தில் மறைந்துவிடும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 ทิ้งโน้ตไว้ที่นี่แล้ว — จะเลือนหายใน 24 ชม." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 buraya not bırakıldı — 24 saat içinde kaybolur" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 нотатку залишено тут — вона зникне за 24 год" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 نوٹ یہاں چھوڑ دیا گیا — یہ 24 گھنٹے میں مٹ جائے گا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 đã để lại ghi chú ở đây — nó sẽ mờ dần sau 24 giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 便签已留在这里 — 24 小时后消失" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "📍 便箋已留在這裡 — 24 小時後會消失" + } + } + } + }, + "command.drop.needs_location" : { + "comment" : "Error when /drop is used without location permission", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ترك ملاحظة يحتاج إلى الموقع — فعّله من شاشة المعلومات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "নোট রাখতে লোকেশন লাগবে — ইনফো স্ক্রিন থেকে চালু করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eine notiz zu hinterlassen braucht den standort — aktiviere ihn im info-bildschirm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "leaving a note needs location — enable it in the info screen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dejar una nota necesita ubicación — actívala en la pantalla de info" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گذاشتن یادداشت به موقعیت مکانی نیاز دارد — از صفحه اطلاعات فعالش کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kailangan ng location para mag-iwan ng note — i-enable ito sa info screen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "laisser une note nécessite la localisation — active-la dans l'écran info" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כדי להשאיר הערה צריך מיקום — הפעילו אותו במסך המידע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोट छोड़ने के लिए लोकेशन चाहिए — इसे इन्फो स्क्रीन में चालू करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "meninggalkan catatan butuh lokasi — aktifkan di layar info" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "per lasciare una nota serve la posizione — attivala nella schermata info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ノートを残すには位置情報が必要 — 情報画面で有効にして" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노트를 남기려면 위치 정보가 필요해요 — 정보 화면에서 켜 주세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "meninggalkan nota memerlukan lokasi — hidupkannya di skrin info" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नोट छोड्न स्थान चाहिन्छ — जानकारी स्क्रिनमा सक्रिय गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "een notitie achterlaten vereist locatie — zet die aan in het infoscherm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zostawienie notki wymaga lokalizacji — włącz ją na ekranie informacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "deixar uma nota precisa de localização — ativa-a no ecrã de info" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "deixar uma nota precisa de localização — ative na tela de info" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "чтобы оставить заметку, нужна геолокация — включи её на экране информации" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "att lämna en anteckning kräver plats — slå på det i infoskärmen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குறிப்பு விட்டுச்செல்ல இருப்பிடம் தேவை — தகவல் திரையில் அதை இயக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การทิ้งโน้ตต้องใช้ตำแหน่งที่ตั้ง — เปิดได้ในหน้าข้อมูล" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "not bırakmak için konum gerekli — bilgi ekranından aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "щоб залишити нотатку, потрібна геолокація — увімкни її на інформаційному екрані" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نوٹ چھوڑنے کے لیے مقام درکار ہے — اسے معلوماتی اسکرین سے آن کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "để lại ghi chú cần vị trí — bật trong màn hình thông tin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "留下便签需要位置信息 — 请在信息页面开启" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "留下便箋需要位置資訊 — 請在資訊畫面開啟" + } + } + } + }, + "command.drop.no_relays" : { + "comment" : "Error when /drop finds no reachable geo relays", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يمكن الوصول إلى أي مرحّلات جغرافية — لم تُترك الملاحظة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কোনো জিও রিলে পাওয়া যাচ্ছে না — নোট রাখা হয়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "keine geo-relays erreichbar — notiz nicht hinterlassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no geo relays reachable — note not left" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no hay relés geo alcanzables — la nota no se dejó" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هیچ رله جغرافیایی در دسترس نیست — یادداشت گذاشته نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang maabot na geo relay — hindi naiwan ang note" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucun relais géo joignable — note non laissée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין ממסרי geo זמינים — ההערה לא הושארה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कोई geo रिले उपलब्ध नहीं — नोट नहीं छोड़ा गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada relay geo yang bisa dijangkau — catatan tidak ditinggalkan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessun relay geo raggiungibile — nota non lasciata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "到達できる geo リレーがない — ノートは残せなかった" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결할 수 있는 geo 릴레이가 없어요 — 노트를 남기지 못했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada geo relay yang dapat dihubungi — nota tidak ditinggalkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "कुनै geo रिले उपलब्ध छैन — नोट छोडिएन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen geo-relays bereikbaar — notitie niet achtergelaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "żaden geo-przekaźnik nie jest osiągalny — notka nie została zostawiona" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum relay geo alcançável — a nota não foi deixada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum relay geo alcançável — a nota não foi deixada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео-релеи недоступны — заметка не оставлена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inga geo-reläer nåbara — anteckningen lämnades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எந்த geo ரிலேயும் எட்டவில்லை — குறிப்பு விடப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ติดต่อรีเลย์ geo ไม่ได้เลย — ยังไม่ได้ทิ้งโน้ตไว้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ulaşılabilir geo rölesi yok — not bırakılmadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео-релеї недоступні — нотатку не залишено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کوئی geo ریلے دستیاب نہیں — نوٹ نہیں چھوڑا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không kết nối được relay geo nào — chưa để lại ghi chú" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可达的地理中继 — 便签没有留下" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可連上的地理中繼 — 便箋沒有留下" + } + } + } + }, + "command.drop.notes_off" : { + "comment" : "Error when /drop is used while location notes are disabled", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاحظات الموقع متوقفة — فعّلها من شاشة المعلومات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লোকেশন নোট বন্ধ আছে — ইনফো স্ক্রিন থেকে চালু করো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "orts-notizen sind aus — aktiviere sie im info-bildschirm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "location notes are off — enable them in the info screen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "las notas de ubicación están desactivadas — actívalas en la pantalla de info" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "یادداشت‌های مکانی خاموش‌اند — از صفحه اطلاعات فعال‌شان کن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naka-off ang mga location note — i-enable ang mga ito sa info screen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "les notes de lieu sont désactivées — active-les dans l'écran info" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הערות מיקום כבויות — הפעילו אותן במסך המידע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लोकेशन नोट बंद हैं — इन्हें इन्फो स्क्रीन में चालू करो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "catatan lokasi sedang nonaktif — aktifkan di layar info" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "le note di posizione sono disattivate — attivale nella schermata info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置ノートはオフ — 情報画面で有効にして" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치 노트가 꺼져 있어요 — 정보 화면에서 켜 주세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota lokasi dimatikan — hidupkannya di skrin info" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान नोटहरू बन्द छन् — जानकारी स्क्रिनमा सक्रिय गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "locatienotities staan uit — zet ze aan in het infoscherm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "notki lokalizacyjne są wyłączone — włącz je na ekranie informacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "as notas de localização estão desligadas — ativa-as no ecrã de info" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de localização estão desativadas — ative-as na tela de info" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "гео-заметки выключены — включи их на экране информации" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "platsanteckningar är av — slå på dem i infoskärmen" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இருப்பிடக் குறிப்புகள் அணைந்துள்ளன — தகவல் திரையில் அவற்றை இயக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โน้ตตามสถานที่ปิดอยู่ — เปิดได้ในหน้าข้อมูล" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "konum notları kapalı — bilgi ekranından aç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "нотатки про місце вимкнено — увімкни їх на інформаційному екрані" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقام کے نوٹس بند ہیں — انہیں معلوماتی اسکرین سے آن کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghi chú vị trí đang tắt — bật trong màn hình thông tin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置便签已关闭 — 请在信息页面开启" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置便箋已關閉 — 請在資訊畫面開啟" + } + } + } + }, + "command.drop.usage" : { + "comment" : "Usage hint for /drop", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /drop " + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /drop <বার্তা>" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /drop " + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /drop " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /drop " + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /drop " + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /drop " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /drop " + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /drop " + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /drop <संदेश>" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /drop " + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /drop " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /drop <メッセージ>" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /drop <메시지>" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /drop " + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /drop <सन्देश>" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /drop " + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /drop " + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /drop " + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /drop " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /drop <сообщение>" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /drop " + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /drop <செய்தி>" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /drop <ข้อความ>" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /drop " + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /drop <повідомлення>" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /drop <پیغام>" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /drop " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/drop <消息>" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/drop <訊息>" + } + } + } + }, + "command.error.favorites_mesh_only" : { + "comment" : "Error when a favorites command is used outside the mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المفضلة متاحة فقط لأقران شبكة mesh في #mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্রিয় তালিকা শুধু #mesh-এ থাকা mesh পিয়ারদের জন্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriten gibt es nur für mesh-peers in #mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorites are only for mesh peers in #mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "los favoritos son solo para peers de la mesh en #mesh" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردعلاقه‌ها فقط برای همتاهای mesh در #mesh هستند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "para lang sa mga mesh peer sa #mesh ang mga favorite" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "les favoris sont réservés aux pairs du mesh dans #mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מועדפים זמינים רק לעמיתי mesh ב-#mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पसंदीदा सिर्फ #mesh के mesh पीयर के लिए हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorit hanya untuk peer mesh di #mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "i preferiti sono solo per i peer mesh in #mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "お気に入りは #mesh のメッシュピア専用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기는 #mesh의 메시 피어에게만 쓸 수 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kegemaran hanya untuk rakan mesh di #mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मनपर्नेहरू #mesh का मेस पियरहरूका लागि मात्र हुन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorieten zijn alleen voor mesh-peers in #mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ulubione są tylko dla peerów mesh w #mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "os favoritos são só para peers mesh no #mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoritos são só para peers mesh no #mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "избранное только для mesh-пиров в #mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriter är bara för mesh-deltagare i #mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பிடித்தவை #mesh இல் உள்ள mesh நபர்களுக்கு மட்டும்தான்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รายการโปรดมีไว้สำหรับคนในเครือข่าย mesh ใน #mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "favoriler sadece #mesh'teki mesh kişileri için" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "обране доступне лише людям у mesh у #mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پسندیدہ صرف #mesh میں mesh والے لوگوں کے لیے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mục yêu thích chỉ dành cho những người trong mesh ở #mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏只对 #mesh 里的 mesh 成员可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最愛只限 #mesh 裡的 mesh 成員使用" + } + } + } + }, + "command.error.groups_mesh_only" : { + "comment" : "Error when a group command is used outside the mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المجموعات متاحة فقط لأقران شبكة mesh في #mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "গ্রুপ শুধু #mesh-এ থাকা mesh পিয়ারদের জন্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "gruppen gibt es nur für mesh-peers in #mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "groups are only for mesh peers in #mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "los grupos son solo para peers de la mesh en #mesh" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "گروه‌ها فقط برای همتاهای mesh در #mesh هستند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "para lang sa mga mesh peer sa #mesh ang mga group" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "les groupes sont réservés aux pairs du mesh dans #mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קבוצות זמינות רק לעמיתי mesh ב-#mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ग्रुप सिर्फ #mesh के mesh पीयर के लिए हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "grup hanya untuk peer mesh di #mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "i gruppi sono solo per i peer mesh in #mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "グループは #mesh のメッシュピア専用" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹은 #mesh의 메시 피어끼리만 쓸 수 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumpulan hanya untuk rakan mesh di #mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "समूहहरू #mesh का मेस पियरहरूका लागि मात्र हुन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "groepen zijn alleen voor mesh-peers in #mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupy są tylko dla peerów mesh w #mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "os grupos são só para peers mesh no #mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupos são só para peers mesh no #mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "группы только для mesh-пиров в #mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "grupper är bara för mesh-deltagare i #mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குழுக்கள் #mesh இல் உள்ள mesh நபர்களுக்கு மட்டும்தான்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กลุ่มมีไว้สำหรับคนในเครือข่าย mesh ใน #mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gruplar sadece #mesh'teki mesh kişileri için" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "групи доступні лише людям у mesh у #mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "گروپ صرف #mesh میں mesh والے لوگوں کے لیے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nhóm chỉ dành cho những người trong mesh ở #mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "群组只对 #mesh 里的 mesh 成员开放" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "群組只限 #mesh 裡的 mesh 成員使用" + } + } + } + }, + "command.error.invalid" : { + "comment" : "Error for an empty or unparseable slash command", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أمر غير صالح" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অবৈধ কমান্ড" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ungültiger befehl" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invalid command" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando no válido" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دستور نامعتبر" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "invalid na command" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "commande invalide" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודה לא חוקית" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अमान्य कमांड" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perintah tidak valid" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando non valido" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無効なコマンド" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잘못된 명령어" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "arahan tidak sah" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अवैध कमाण्ड" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ongeldig commando" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nieprawidłowe polecenie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando inválido" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando inválido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неверная команда" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ogiltigt kommando" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தவறான கட்டளை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คำสั่งไม่ถูกต้อง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "geçersiz komut" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "неправильна команда" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "غلط کمانڈ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lệnh không hợp lệ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效命令" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無效的指令" + } + } + } + }, + "command.error.ping_mesh_only" : { + "comment" : "Error when /ping is used outside the mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping يعمل فقط مع أقران شبكة mesh في #mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping শুধু #mesh-এ থাকা mesh পিয়ারদের সাথে কাজ করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping funktioniert nur für mesh-peers in #mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping only works for mesh peers in #mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping solo funciona con peers de la mesh en #mesh" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping فقط برای همتاهای mesh در #mesh کار می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "gumagana lang ang ping sa mga mesh peer sa #mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping ne fonctionne qu'avec les pairs du mesh dans #mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping עובד רק עם עמיתי mesh ב-#mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping सिर्फ #mesh के mesh पीयर के साथ काम करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping hanya berfungsi untuk peer mesh di #mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping funziona solo per i peer mesh in #mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping は #mesh のメッシュピアにのみ使える" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping은 #mesh의 메시 피어에게만 작동해요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping hanya berfungsi untuk rakan mesh di #mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping #mesh का मेस पियरहरूका लागि मात्र काम गर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping werkt alleen voor mesh-peers in #mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping działa tylko dla peerów mesh w #mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o ping só funciona para peers mesh no #mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping só funciona para peers mesh no #mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping работает только для mesh-пиров в #mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping funkar bara för mesh-deltagare i #mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping #mesh இல் உள்ள mesh நபர்களுக்கு மட்டுமே வேலை செய்யும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping ใช้ได้กับคนในเครือข่าย mesh ใน #mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping sadece #mesh'teki mesh kişileri için çalışır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping працює лише для людей у mesh у #mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping صرف #mesh میں mesh والے لوگوں کے لیے کام کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping chỉ hoạt động với những người trong mesh ở #mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping 只对 #mesh 里的 mesh 成员有效" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping 只對 #mesh 裡的 mesh 成員有效" + } + } + } + }, + "command.error.trace_mesh_only" : { + "comment" : "Error when /trace is used outside the mesh channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace يعمل فقط مع أقران شبكة mesh في #mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace শুধু #mesh-এ থাকা mesh পিয়ারদের সাথে কাজ করে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace funktioniert nur für mesh-peers in #mesh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace only works for mesh peers in #mesh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace solo funciona con peers de la mesh en #mesh" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace فقط برای همتاهای mesh در #mesh کار می‌کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "gumagana lang ang trace sa mga mesh peer sa #mesh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace ne fonctionne qu'avec les pairs du mesh dans #mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace עובד רק עם עמיתי mesh ב-#mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace सिर्फ #mesh के mesh पीयर के साथ काम करता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace hanya berfungsi untuk peer mesh di #mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace funziona solo per i peer mesh in #mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace は #mesh のメッシュピアにのみ使える" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace는 #mesh의 메시 피어에게만 작동해요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace hanya berfungsi untuk rakan mesh di #mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace #mesh का मेस पियरहरूका लागि मात्र काम गर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace werkt alleen voor mesh-peers in #mesh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace działa tylko dla peerów mesh w #mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "o trace só funciona para peers mesh no #mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace só funciona para peers mesh no #mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace работает только для mesh-пиров в #mesh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace funkar bara för mesh-deltagare i #mesh" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace #mesh இல் உள்ள mesh நபர்களுக்கு மட்டுமே வேலை செய்யும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace ใช้ได้กับคนในเครือข่าย mesh ใน #mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace sadece #mesh'teki mesh kişileri için çalışır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace працює лише для людей у mesh у #mesh" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace صرف #mesh میں mesh والے لوگوں کے لیے کام کرتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace chỉ hoạt động với những người trong mesh ở #mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace 只对 #mesh 里的 mesh 成员有效" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "trace 只對 #mesh 裡的 mesh 成員有效" + } + } + } + }, + "command.error.unknown" : { + "comment" : "Error for an unrecognized slash command; placeholder is the typed command", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أمر غير معروف: %@ — اكتب /help لعرض الأوامر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অজানা কমান্ড: %@ — কমান্ডের তালিকা দেখতে /help লেখো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "unbekannter befehl: %@ — tippe /help für alle befehle" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unknown command: %@ — type /help for commands" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando desconocido: %@ — escribe /help para ver los comandos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دستور ناشناخته: %@ — برای دیدن دستورها /help را بنویس" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi kilalang command: %@ — i-type ang /help para sa mga command" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "commande inconnue : %@ — tape /help pour voir les commandes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודה לא מוכרת: %@ — הקלידו /help לרשימת הפקודות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनजान कमांड: %@ — कमांड देखने के लिए /help लिखो" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perintah tak dikenal: %@ — ketik /help untuk melihat daftar perintah" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando sconosciuto: %@ — digita /help per i comandi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明なコマンド: %@ — /help でコマンド一覧を表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알 수 없는 명령어: %@ — /help를 입력하면 명령어 목록을 볼 수 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "arahan tidak dikenali: %@ — taip /help untuk senarai arahan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अज्ञात कमाण्ड: %@ — कमाण्डहरूका लागि /help टाइप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "onbekend commando: %@ — typ /help voor commando's" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nieznane polecenie: %@ — wpisz /help, aby zobaczyć polecenia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando desconhecido: %@ — escreve /help para veres os comandos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "comando desconhecido: %@ — digite /help para ver os comandos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неизвестная команда: %@ — набери /help для списка команд" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "okänt kommando: %@ — skriv /help för kommandon" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தெரியாத கட்டளை: %@ — கட்டளைகளுக்கு /help என தட்டச்சு செய்யவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่รู้จักคำสั่ง: %@ — พิมพ์ /help เพื่อดูคำสั่งทั้งหมด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bilinmeyen komut: %@ — komutlar için /help yaz" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "невідома команда: %@ — введи /help для списку команд" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "نامعلوم کمانڈ: %@ — کمانڈز کے لیے /help لکھیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lệnh không xác định: %@ — gõ /help để xem các lệnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知命令:%@ — 输入 /help 查看命令" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知的指令:%@ — 輸入 /help 查看指令" + } + } + } + }, + "command.fav.added" : { + "comment" : "Confirmation after /fav", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمت إضافة %@ إلى المفضلة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে প্রিয় তালিকায় যোগ করা হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ zu den favoriten hinzugefügt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "added %@ to favorites" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ añadido a favoritos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ به موردعلاقه‌ها اضافه شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "idinagdag si %@ sa mga favorite" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ajouté aux favoris" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נוסף למועדפים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को पसंदीदा में जोड़ा गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ditambahkan ke favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ aggiunto ai preferiti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ をお気に入りに追加した" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 즐겨찾기에 추가했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ditambah ke kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मनपर्नेहरूमा थपियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ toegevoegd aan favorieten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dodano %@ do ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ adicionado aos favoritos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ adicionado aos favoritos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ добавлен в избранное" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "la till %@ i favoriter" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ பிடித்தவர்களில் சேர்க்கப்பட்டார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เพิ่ม %@ ในรายการโปรดแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ favorilere eklendi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ додано до обраного" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو پسندیدہ میں شامل کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã thêm %@ vào mục yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已将 %@ 加入收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已將 %@ 加入最愛" + } + } + } + }, + "command.fav.already" : { + "comment" : "Reply when /fav targets an existing favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ضمن المفضلة بالفعل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ আগে থেকেই প্রিয় তালিকায় আছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ist bereits ein favorit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is already a favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ya es un favorito" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ از قبل موردعلاقه است" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "favorite na si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ est déjà en favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ כבר במועדפים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पहले से पसंदीदा है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sudah jadi favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ è già nei preferiti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ はすでにお気に入り" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@은(는) 이미 즐겨찾기예요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sudah menjadi kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पहिले नै मनपर्ने हो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is al een favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ już jest w ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ já é um favorito" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ já é um favorito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ уже в избранном" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ är redan en favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ஏற்கனவே பிடித்தவர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ อยู่ในรายการโปรดอยู่แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ zaten favori" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ вже в обраному" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ پہلے سے پسندیدہ ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ đã có trong mục yêu thích rồi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 已在收藏中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 已在最愛中" + } + } + } + }, + "command.fav.not_favorite" : { + "comment" : "Reply when /unfav targets someone who isn't a favorite", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ليس ضمن المفضلة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ প্রিয় তালিকায় নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ist kein favorit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is not a favorite" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ no es un favorito" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ موردعلاقه نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi favorite si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ n'est pas en favori" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ לא במועדפים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पसंदीदा नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bukan favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ non è nei preferiti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ はお気に入りじゃない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@은(는) 즐겨찾기가 아니에요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bukan kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मनपर्ने होइन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is geen favoriet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ nie jest w ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ não é um favorito" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ não é um favorito" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ не в избранном" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ är inte en favorit" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ பிடித்தவர்களில் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ไม่ได้อยู่ในรายการโปรด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ favori değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ не в обраному" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ پسندیدہ نہیں ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ không có trong mục yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 不在收藏中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 不在最愛中" + } + } + } + }, + "command.fav.not_found" : { + "comment" : "Error when /fav or /unfav can't resolve the nickname", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر العثور على القرين: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "পিয়ার খুঁজে পাওয়া যাচ্ছে না: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer nicht gefunden: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "can't find peer: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se encuentra el peer: %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "همتا پیدا نشد: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi mahanap ang peer: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pair introuvable : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא נמצא עמית: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पीयर नहीं मिला: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer tidak ditemukan: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer non trovato: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ピアが見つからない: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "피어를 찾을 수 없어요: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "rakan tidak dijumpai: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पियर फेला परेन: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "peer niet gevonden: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można znaleźć peera: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não encontro o peer: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não achei o peer: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "пир не найден: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "hittar inte personen: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நபரை கண்டுபிடிக்க முடியவில்லை: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "หาคนนี้ไม่เจอ: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kişi bulunamıyor: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося знайти людину: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ شخص نہیں مل رہا: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không tìm thấy người này: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "找不到这个人:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "找不到這個人:%@" + } + } + } + }, + "command.fav.removed" : { + "comment" : "Confirmation after /unfav", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمت إزالة %@ من المفضلة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে প্রিয় তালিকা থেকে সরানো হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ aus den favoriten entfernt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "removed %@ from favorites" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ eliminado de favoritos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ از موردعلاقه‌ها حذف شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "inalis si %@ sa mga favorite" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ retiré des favoris" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ הוסר מהמועדפים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को पसंदीदा से हटाया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dihapus dari favorit" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ rimosso dai preferiti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ をお気に入りから外した" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@을(를) 즐겨찾기에서 삭제했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dibuang daripada kegemaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मनपर्नेहरूबाट हटाइयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ verwijderd uit favorieten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usunięto %@ z ulubionych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ removido dos favoritos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ removido dos favoritos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ удалён из избранного" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tog bort %@ från favoriter" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ பிடித்தவர்களிலிருந்து நீக்கப்பட்டார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "นำ %@ ออกจากรายการโปรดแล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ favorilerden çıkarıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ вилучено з обраного" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو پسندیدہ سے نکال دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã xóa %@ khỏi mục yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已将 %@ 移出收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已將 %@ 移出最愛" + } + } + } + }, + "command.group.usage" : { + "comment" : "Usage hint for /group subcommands", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /group create · invite @name · remove @name · leave · list" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /group create <নাম> · invite @name · remove @name · leave · list" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /group create · invite @name · remove @name · leave · list" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /group create · invite @name · remove @name · leave · list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create · invite @name · remove @name · leave · list" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /group create · invite @name · remove @name · leave · list" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /group create · invite @name · remove @name · leave · list" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /group create · invite @name · remove @name · leave · list" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /group create · invite @name · remove @name · leave · list" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /group create <नाम> · invite @name · remove @name · leave · list" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /group create · invite @name · remove @name · leave · list" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create · invite @name · remove @name · leave · list" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /group create <名前> · invite @name · remove @name · leave · list" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /group create <이름> · invite @name · remove @name · leave · list" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /group create · invite @name · remove @name · leave · list" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /group create <नाम> · invite @name · remove @name · leave · list" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /group create · invite @name · remove @name · leave · list" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /group create · invite @name · remove @name · leave · list" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create · invite @name · remove @name · leave · list" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /group create · invite @name · remove @name · leave · list" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /group create <название> · invite @name · remove @name · leave · list" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /group create · invite @name · remove @name · leave · list" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /group create <பெயர்> · invite @name · remove @name · leave · list" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /group create <ชื่อ> · invite @name · remove @name · leave · list" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /group create · invite @name · remove @name · leave · list" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /group create <назва> · invite @name · remove @name · leave · list" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /group create <نام> · invite @name · remove @name · leave · list" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /group create · invite @name · remove @name · leave · list" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/group create <名称> · invite @name · remove @name · leave · list" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/group create <名稱> · invite @name · remove @name · leave · list" + } + } + } + }, + "command.help.text" : { + "comment" : "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأوامر:\n/msg @name [message] — بدء محادثة خاصة\n/who — عرض من هنا\n/clear — مسح هذه المحادثة\n/hug @name — إرسال عناق\n/slap @name — صفعة بسمكة تراوت ضخمة\n/block @name · /unblock @name\n/fav @name · /unfav @name — المفضلة (mesh فقط)\n/group create — إنشاء مجموعة مشفّرة\n/group invite @name · /group remove @name — إدارة الأعضاء (المنشئ)\n/group leave · /group list — مغادرة مجموعاتك أو عرضها\n/ping @name — قياس زمن الذهاب والإياب (mesh فقط)\n/trace @name — مسار mesh المقدّر (mesh فقط)\n/pay — إرسال رمز cashu (ecash) في هذه المحادثة\n/drop — تثبيت ملاحظة في هذا المكان لمدة 24 ساعة (يحتاج إلى الموقع)\n/help — هذه القائمة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কমান্ড:\n/msg @name [message] — প্রাইভেট চ্যাট শুরু করো\n/who — কে কে আছে দেখো\n/clear — এই চ্যাট মুছে ফেলো\n/hug @name — একটা আলিঙ্গন পাঠাও\n/slap @name — বিশাল এক ট্রাউট মাছ দিয়ে চড় মারো\n/block @name · /unblock @name\n/fav @name · /unfav @name — প্রিয় তালিকা (শুধু mesh)\n/group create — এনক্রিপ্ট করা গ্রুপ শুরু করো\n/group invite @name · /group remove @name — সদস্য ব্যবস্থাপনা (নির্মাতা)\n/group leave · /group list — গ্রুপ ছাড়ো বা তালিকা দেখো\n/ping @name — রাউন্ড-ট্রিপ সময় মাপো (শুধু mesh)\n/trace @name — আনুমানিক mesh পথ (শুধু mesh)\n/pay — এই চ্যাটে একটি cashu ecash টোকেন পাঠাও\n/drop — এই জায়গায় ২৪ ঘণ্টার জন্য একটা নোট আটকে দাও (লোকেশন লাগবে)\n/help — এই তালিকা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "befehle:\n/msg @name [message] — privaten chat starten\n/who — zeigen, wer hier ist\n/clear — diesen chat leeren\n/hug @name — eine umarmung senden\n/slap @name — mit einer großen forelle ohrfeigen\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriten (nur mesh)\n/group create — eine verschlüsselte gruppe starten\n/group invite @name · /group remove @name — mitglieder verwalten (ersteller)\n/group leave · /group list — gruppen verlassen oder auflisten\n/ping @name — round-trip-zeit messen (nur mesh)\n/trace @name — geschätzter mesh-pfad (nur mesh)\n/pay — einen cashu-ecash-token in diesem chat senden\n/drop — eine notiz für 24h an diesem ort anpinnen (braucht standort)\n/help — diese liste" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay — send a cashu ecash token in this chat\n/drop — pin a note to this place for 24h (needs location)\n/help — this list" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "comandos:\n/msg @name [message] — iniciar un chat privado\n/who — listar quién está aquí\n/clear — limpiar este chat\n/hug @name — enviar un abrazo\n/slap @name — dar un golpe con una trucha enorme\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (solo mesh)\n/group create — iniciar un grupo cifrado\n/group invite @name · /group remove @name — gestionar miembros (creador)\n/group leave · /group list — salir o listar tus grupos\n/ping @name — medir el tiempo de ida y vuelta (solo mesh)\n/trace @name — ruta estimada por la mesh (solo mesh)\n/pay — enviar un token ecash de cashu en este chat\n/drop — fijar una nota en este lugar por 24h (necesita ubicación)\n/help — esta lista" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "دستورها:\n/msg @name [message] — شروع چت خصوصی\n/who — فهرست کسانی که اینجا هستند\n/clear — پاک کردن این چت\n/hug @name — فرستادن یک بغل\n/slap @name — سیلی با یک قزل‌آلای گنده\n/block @name · /unblock @name\n/fav @name · /unfav @name — موردعلاقه‌ها (فقط mesh)\n/group create — شروع یک گروه رمزگذاری‌شده\n/group invite @name · /group remove @name — مدیریت اعضا (سازنده)\n/group leave · /group list — ترک گروه‌هایت یا فهرست‌شان\n/ping @name — اندازه‌گیری زمان رفت‌وبرگشت (فقط mesh)\n/trace @name — مسیر تخمینی mesh (فقط mesh)\n/pay — ارسال توکن ecash از cashu در این چت\n/drop — سنجاق کردن یادداشت به این مکان برای ۲۴ ساعت (به موقعیت نیاز دارد)\n/help — همین فهرست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga command:\n/msg @name [message] — magsimula ng pribadong chat\n/who — ilista kung sino ang narito\n/clear — i-clear ang chat na ito\n/hug @name — magpadala ng yakap\n/slap @name — sampalin gamit ang malaking trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — mga favorite (mesh lang)\n/group create — magsimula ng encrypted na group\n/group invite @name · /group remove @name — pamahalaan ang mga miyembro (creator)\n/group leave · /group list — umalis o ilista ang mga group mo\n/ping @name — sukatin ang round-trip time (mesh lang)\n/trace @name — tinatayang mesh path (mesh lang)\n/pay — magpadala ng cashu ecash token sa chat na ito\n/drop — mag-pin ng note sa lugar na ito nang 24h (kailangan ng location)\n/help — ang listahang ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "commandes :\n/msg @name [message] — démarrer un chat privé\n/who — lister qui est là\n/clear — effacer ce chat\n/hug @name — envoyer un câlin\n/slap @name — mettre une claque avec une grosse truite\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoris (mesh uniquement)\n/group create — démarrer un groupe chiffré\n/group invite @name · /group remove @name — gérer les membres (créateur)\n/group leave · /group list — quitter ou lister tes groupes\n/ping @name — mesurer le temps aller-retour (mesh uniquement)\n/trace @name — chemin mesh estimé (mesh uniquement)\n/pay — envoyer un token ecash cashu dans ce chat\n/drop — épingler une note à cet endroit pour 24h (nécessite la localisation)\n/help — cette liste" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פקודות:\n/msg @name [message] — פתיחת צ'אט פרטי\n/who — מי נמצא כאן\n/clear — ניקוי הצ'אט הזה\n/hug @name — שליחת חיבוק\n/slap @name — סטירה עם פורל ענק\n/block @name · /unblock @name\n/fav @name · /unfav @name — מועדפים (mesh בלבד)\n/group create — פתיחת קבוצה מוצפנת\n/group invite @name · /group remove @name — ניהול חברים (יוצר הקבוצה)\n/group leave · /group list — עזיבה או הצגת הקבוצות שלכם\n/ping @name — מדידת זמן הלוך-חזור (mesh בלבד)\n/trace @name — נתיב mesh משוער (mesh בלבד)\n/pay — שליחת טוקן ecash של cashu בצ'אט הזה\n/drop — הצמדת הערה למקום הזה ל-24 שעות (דורש מיקום)\n/help — הרשימה הזאת" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कमांड:\n/msg @name [message] — निजी चैट शुरू करो\n/who — देखो यहाँ कौन-कौन है\n/clear — यह चैट साफ़ करो\n/hug @name — झप्पी भेजो\n/slap @name — एक बड़ी ट्राउट मछली से थप्पड़ मारो\n/block @name · /unblock @name\n/fav @name · /unfav @name — पसंदीदा (सिर्फ mesh)\n/group create — एक एन्क्रिप्टेड ग्रुप शुरू करो\n/group invite @name · /group remove @name — सदस्य प्रबंधन (निर्माता)\n/group leave · /group list — ग्रुप छोड़ो या अपने ग्रुप की सूची देखो\n/ping @name — राउंड-ट्रिप समय मापो (सिर्फ mesh)\n/trace @name — अनुमानित mesh रास्ता (सिर्फ mesh)\n/pay — इस चैट में cashu ecash टोकन भेजो\n/drop — इस जगह 24 घंटे के लिए नोट पिन करो (लोकेशन चाहिए)\n/help — यही सूची" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perintah:\n/msg @name [message] — mulai chat pribadi\n/who — daftar siapa saja yang ada di sini\n/clear — bersihkan chat ini\n/hug @name — kirim pelukan\n/slap @name — tampar dengan ikan trout besar\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorit (khusus mesh)\n/group create — mulai grup terenkripsi\n/group invite @name · /group remove @name — kelola anggota (pembuat)\n/group leave · /group list — keluar dari grup atau lihat daftar grupmu\n/ping @name — ukur waktu bolak-balik (khusus mesh)\n/trace @name — perkiraan jalur mesh (khusus mesh)\n/pay — kirim token ecash cashu di chat ini\n/drop — sematkan catatan di tempat ini selama 24 jam (butuh lokasi)\n/help — daftar ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "comandi:\n/msg @name [messaggio] — avvia una chat privata\n/who — elenca chi c'è\n/clear — pulisci questa chat\n/hug @name — manda un abbraccio\n/slap @name — schiaffeggia con una grossa trota\n/block @name · /unblock @name\n/fav @name · /unfav @name — preferiti (solo mesh)\n/group create — avvia un gruppo cifrato\n/group invite @name · /group remove @name — gestisci i membri (creatore)\n/group leave · /group list — esci dai tuoi gruppi o elencali\n/ping @name — misura il tempo di andata e ritorno (solo mesh)\n/trace @name — percorso mesh stimato (solo mesh)\n/pay — invia un token ecash cashu in questa chat\n/drop — appunta una nota in questo posto per 24h (serve la posizione)\n/help — questa lista" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コマンド:\n/msg @name [メッセージ] — プライベートチャットを開始\n/who — 誰がいるか一覧表示\n/clear — このチャットをクリア\n/hug @name — ハグを送る\n/slap @name — 大きなマスでひっぱたく\n/block @name · /unblock @name\n/fav @name · /unfav @name — お気に入り (メッシュのみ)\n/group create <名前> — 暗号化グループを開始\n/group invite @name · /group remove @name — メンバー管理 (作成者)\n/group leave · /group list — グループから抜ける・一覧表示\n/ping @name — 往復時間を測定 (メッシュのみ)\n/trace @name — 推定メッシュ経路 (メッシュのみ)\n/pay <トークン> — このチャットで cashu ecash トークンを送る\n/drop <メッセージ> — この場所に24時間ノートをピン留め (位置情報が必要)\n/help — この一覧" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "명령어:\n/msg @name [메시지] — 개인 채팅 시작\n/who — 여기 누가 있는지 보기\n/clear — 이 채팅 지우기\n/hug @name — 포옹 보내기\n/slap @name — 큰 송어로 찰싹 때리기\n/block @name · /unblock @name\n/fav @name · /unfav @name — 즐겨찾기 (메시 전용)\n/group create <이름> — 암호화 그룹 만들기\n/group invite @name · /group remove @name — 멤버 관리 (만든 사람)\n/group leave · /group list — 그룹 나가기 또는 목록 보기\n/ping @name — 왕복 시간 측정 (메시 전용)\n/trace @name — 예상 메시 경로 (메시 전용)\n/pay <토큰> — 이 채팅에서 cashu ecash 토큰 보내기\n/drop <메시지> — 이 장소에 24시간 노트 고정 (위치 정보 필요)\n/help — 이 목록" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "arahan:\n/msg @name [mesej] — mulakan sembang peribadi\n/who — senaraikan siapa yang ada di sini\n/clear — kosongkan sembang ini\n/hug @name — hantar pelukan\n/slap @name — tampar dengan ikan trout besar\n/block @name · /unblock @name\n/fav @name · /unfav @name — kegemaran (mesh sahaja)\n/group create — mulakan kumpulan tersulit\n/group invite @name · /group remove @name — urus ahli (pencipta)\n/group leave · /group list — keluar atau senaraikan kumpulan anda\n/ping @name — ukur masa pergi balik (mesh sahaja)\n/trace @name — laluan mesh anggaran (mesh sahaja)\n/pay — hantar token ecash cashu dalam sembang ini\n/drop — sematkan nota di tempat ini selama 24 jam (perlukan lokasi)\n/help — senarai ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "कमाण्डहरू:\n/msg @name [सन्देश] — निजी च्याट सुरु गर्नुहोस्\n/who — यहाँ को छ भनेर हेर्नुहोस्\n/clear — यो च्याट खाली गर्नुहोस्\n/hug @name — अँगालो पठाउनुहोस्\n/slap @name — ठूलो ट्राउट माछाले हिर्काउनुहोस्\n/block @name · /unblock @name\n/fav @name · /unfav @name — मनपर्नेहरू (मेस मात्र)\n/group create <नाम> — इन्क्रिप्टेड समूह सुरु गर्नुहोस्\n/group invite @name · /group remove @name — सदस्य व्यवस्थापन (सर्जक)\n/group leave · /group list — समूह छोड्नुहोस् वा सूची हेर्नुहोस्\n/ping @name — राउन्ड-ट्रिप समय नाप्नुहोस् (मेस मात्र)\n/trace @name — अनुमानित मेस बाटो (मेस मात्र)\n/pay <टोकन> — यो च्याटमा cashu ecash टोकन पठाउनुहोस्\n/drop <सन्देश> — यो ठाउँमा 24 घण्टाका लागि नोट राख्नुहोस् (स्थान चाहिन्छ)\n/help — यो सूची" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "commando's:\n/msg @name [bericht] — start een privéchat\n/who — toon wie er is\n/clear — wis deze chat\n/hug @name — stuur een knuffel\n/slap @name — sla met een grote forel\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorieten (alleen mesh)\n/group create — start een versleutelde groep\n/group invite @name · /group remove @name — beheer leden (maker)\n/group leave · /group list — verlaat of toon je groepen\n/ping @name — meet de rondreistijd (alleen mesh)\n/trace @name — geschat mesh-pad (alleen mesh)\n/pay — stuur een cashu-ecash-token in deze chat\n/drop — pin 24 uur lang een notitie op deze plek (vereist locatie)\n/help — deze lijst" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "polecenia:\n/msg @name [wiadomość] — rozpocznij prywatny czat\n/who — pokaż, kto tu jest\n/clear — wyczyść ten czat\n/hug @name — wyślij przytulasa\n/slap @name — trzaśnij wielkim pstrągiem\n/block @name · /unblock @name\n/fav @name · /unfav @name — ulubione (tylko mesh)\n/group create — załóż szyfrowaną grupę\n/group invite @name · /group remove @name — zarządzaj członkami (założyciel)\n/group leave · /group list — opuść grupę lub pokaż swoje grupy\n/ping @name — zmierz czas podróży w obie strony (tylko mesh)\n/trace @name — szacowana ścieżka mesh (tylko mesh)\n/pay — wyślij token ecash cashu na tym czacie\n/drop — przypnij notkę w tym miejscu na 24 h (wymaga lokalizacji)\n/help — ta lista" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "comandos:\n/msg @name [mensagem] — inicia uma conversa privada\n/who — lista quem está aqui\n/clear — limpa esta conversa\n/hug @name — envia um abraço\n/slap @name — dá uma chapada com uma truta enorme\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (só mesh)\n/group create — cria um grupo encriptado\n/group invite @name · /group remove @name — gere os membros (criador)\n/group leave · /group list — sai dos teus grupos ou lista-os\n/ping @name — mede o tempo de ida e volta (só mesh)\n/trace @name — caminho mesh estimado (só mesh)\n/pay — envia um token ecash cashu nesta conversa\n/drop — fixa uma nota neste sítio por 24h (precisa de localização)\n/help — esta lista" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "comandos:\n/msg @name [mensagem] — comece um chat privado\n/who — veja quem está aqui\n/clear — limpe este chat\n/hug @name — mande um abraço\n/slap @name — dê um tapa com uma truta gigante\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoritos (só mesh)\n/group create — crie um grupo criptografado\n/group invite @name · /group remove @name — gerencie os membros (criador)\n/group leave · /group list — saia dos seus grupos ou liste-os\n/ping @name — meça o tempo de ida e volta (só mesh)\n/trace @name — caminho mesh estimado (só mesh)\n/pay — envie um token ecash cashu neste chat\n/drop — fixe uma nota neste lugar por 24h (precisa de localização)\n/help — esta lista" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "команды:\n/msg @name [сообщение] — начать личный чат\n/who — показать, кто здесь\n/clear — очистить этот чат\n/hug @name — отправить обнимашку\n/slap @name — шлёпнуть большой форелью\n/block @name · /unblock @name\n/fav @name · /unfav @name — избранное (только mesh)\n/group create <название> — создать зашифрованную группу\n/group invite @name · /group remove @name — управление участниками (создатель)\n/group leave · /group list — выйти из групп или показать их список\n/ping @name — измерить время туда-обратно (только mesh)\n/trace @name — примерный путь по mesh (только mesh)\n/pay <токен> — отправить cashu-токен (ecash) в этот чат\n/drop <сообщение> — закрепить заметку в этом месте на 24 ч (нужна геолокация)\n/help — этот список" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kommandon:\n/msg @name [message] — starta en privat chatt\n/who — lista vilka som är här\n/clear — rensa den här chatten\n/hug @name — skicka en kram\n/slap @name — daska till med en stor forell\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriter (endast mesh)\n/group create — starta en krypterad grupp\n/group invite @name · /group remove @name — hantera medlemmar (skapare)\n/group leave · /group list — lämna eller lista dina grupper\n/ping @name — mät tur- och returtid (endast mesh)\n/trace @name — uppskattad mesh-väg (endast mesh)\n/pay — skicka en cashu ecash-token i den här chatten\n/drop — fäst en anteckning på den här platsen i 24h (kräver plats)\n/help — den här listan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "கட்டளைகள்:\n/msg @name [message] — தனி அரட்டையைத் தொடங்கு\n/who — இங்கு யார் இருக்கிறார்கள் என்று பட்டியலிடு\n/clear — இந்த அரட்டையை அழி\n/hug @name — ஒரு அணைப்பை அனுப்பு\n/slap @name — ஒரு பெரிய ட்ரவுட் மீனால் அறை\n/block @name · /unblock @name\n/fav @name · /unfav @name — பிடித்தவை (mesh மட்டும்)\n/group create — மறைகுறியாக்கப்பட்ட குழுவைத் தொடங்கு\n/group invite @name · /group remove @name — உறுப்பினர்களை நிர்வகி (உருவாக்கியவர்)\n/group leave · /group list — உங்கள் குழுக்களிலிருந்து வெளியேறு அல்லது பட்டியலிடு\n/ping @name — சுற்றுப்பயண நேரத்தை அளவிடு (mesh மட்டும்)\n/trace @name — மதிப்பிடப்பட்ட mesh பாதை (mesh மட்டும்)\n/pay — இந்த அரட்டையில் cashu ecash டோக்கனை அனுப்பு\n/drop — இந்த இடத்தில் 24 மணி நேரத்திற்கு ஒரு குறிப்பை பொருத்து (இருப்பிடம் தேவை)\n/help — இந்த பட்டியல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คำสั่ง:\n/msg @name [message] — เริ่มแชทส่วนตัว\n/who — ดูว่าใครอยู่ที่นี่บ้าง\n/clear — ล้างแชทนี้\n/hug @name — ส่งอ้อมกอด\n/slap @name — ตบด้วยปลาเทราต์ตัวใหญ่\n/block @name · /unblock @name\n/fav @name · /unfav @name — รายการโปรด (เฉพาะ mesh)\n/group create — เริ่มกลุ่มที่เข้ารหัส\n/group invite @name · /group remove @name — จัดการสมาชิก (ผู้สร้าง)\n/group leave · /group list — ออกจากกลุ่มหรือดูรายชื่อกลุ่มของคุณ\n/ping @name — วัดเวลาไปกลับ (เฉพาะ mesh)\n/trace @name — เส้นทาง mesh โดยประมาณ (เฉพาะ mesh)\n/pay — ส่งโทเคน ecash ของ cashu ในแชทนี้\n/drop — ปักโน้ตไว้ที่นี่นาน 24 ชม. (ต้องใช้ตำแหน่งที่ตั้ง)\n/help — รายการนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "komutlar:\n/msg @name [message] — özel sohbet başlat\n/who — burada kimler var, listele\n/clear — bu sohbeti temizle\n/hug @name — sarılma gönder\n/slap @name — koca bir alabalıkla tokat at\n/block @name · /unblock @name\n/fav @name · /unfav @name — favoriler (sadece mesh)\n/group create — şifreli bir grup başlat\n/group invite @name · /group remove @name — üyeleri yönet (kurucu)\n/group leave · /group list — gruplarından ayrıl veya listele\n/ping @name — gidiş-dönüş süresini ölç (sadece mesh)\n/trace @name — tahmini mesh yolu (sadece mesh)\n/pay — bu sohbette cashu ecash token'ı gönder\n/drop — buraya 24 saatliğine not sabitle (konum gerekli)\n/help — bu liste" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "команди:\n/msg @name [message] — почати приватний чат\n/who — показати, хто тут є\n/clear — очистити цей чат\n/hug @name — надіслати обійми\n/slap @name — ляснути великою фореллю\n/block @name · /unblock @name\n/fav @name · /unfav @name — обране (лише mesh)\n/group create — створити зашифровану групу\n/group invite @name · /group remove @name — керувати учасниками (творець)\n/group leave · /group list — вийти з груп або показати список\n/ping @name — виміряти час проходження туди й назад (лише mesh)\n/trace @name — орієнтовний шлях у mesh (лише mesh)\n/pay — надіслати cashu ecash-токен у цей чат\n/drop — закріпити нотатку в цьому місці на 24 год (потрібна геолокація)\n/help — цей список" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کمانڈز:\n/msg @name [message] — نجی چیٹ شروع کریں\n/who — دیکھیں یہاں کون کون ہے\n/clear — یہ چیٹ صاف کریں\n/hug @name — جھپی بھیجیں\n/slap @name — ایک بڑی ٹراؤٹ مچھلی سے تھپڑ ماریں\n/block @name · /unblock @name\n/fav @name · /unfav @name — پسندیدہ (صرف mesh)\n/group create — ایک انکرپٹڈ گروپ بنائیں\n/group invite @name · /group remove @name — اراکین کا انتظام کریں (بنانے والا)\n/group leave · /group list — اپنے گروپ چھوڑیں یا فہرست دیکھیں\n/ping @name — راؤنڈ ٹرپ وقت ناپیں (صرف mesh)\n/trace @name — تخمینی mesh راستہ (صرف mesh)\n/pay — اس چیٹ میں cashu ای کیش ٹوکن بھیجیں\n/drop — اس جگہ 24 گھنٹے کے لیے نوٹ لگائیں (مقام درکار)\n/help — یہ فہرست" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "các lệnh:\n/msg @name [message] — bắt đầu trò chuyện riêng\n/who — xem ai đang ở đây\n/clear — xóa cuộc trò chuyện này\n/hug @name — gửi một cái ôm\n/slap @name — tát bằng một con cá hồi vân to\n/block @name · /unblock @name\n/fav @name · /unfav @name — mục yêu thích (chỉ mesh)\n/group create — tạo nhóm mã hóa\n/group invite @name · /group remove @name — quản lý thành viên (người tạo)\n/group leave · /group list — rời hoặc xem danh sách nhóm của bạn\n/ping @name — đo thời gian khứ hồi (chỉ mesh)\n/trace @name — đường đi mesh ước tính (chỉ mesh)\n/pay — gửi token ecash cashu trong cuộc trò chuyện này\n/drop — ghim ghi chú tại nơi này trong 24 giờ (cần vị trí)\n/help — danh sách này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "命令:\n/msg @name [message] — 开始私聊\n/who — 列出谁在这里\n/clear — 清空当前聊天\n/hug @name — 送出一个拥抱\n/slap @name — 用一条大鳟鱼拍过去\n/block @name · /unblock @name\n/fav @name · /unfav @name — 收藏(仅 mesh)\n/group create — 创建加密群组\n/group invite @name · /group remove @name — 管理成员(创建者)\n/group leave · /group list — 退出或列出你的群组\n/ping @name — 测量往返时间(仅 mesh)\n/trace @name — 估计的 mesh 路径(仅 mesh)\n/pay — 在当前聊天发送 cashu ecash 代币\n/drop — 在这个地方钉一条便签,保留 24 小时(需要位置)\n/help — 本列表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "指令:\n/msg @name [message] — 開始私人聊天\n/who — 列出誰在這裡\n/clear — 清除這個聊天\n/hug @name — 送出一個擁抱\n/slap @name — 用一條大鱒魚巴下去\n/block @name · /unblock @name\n/fav @name · /unfav @name — 最愛(僅限 mesh)\n/group create — 建立加密群組\n/group invite @name · /group remove @name — 管理成員(建立者)\n/group leave · /group list — 離開或列出你的群組\n/ping @name — 測量來回時間(僅限 mesh)\n/trace @name — 估計的 mesh 路徑(僅限 mesh)\n/pay — 在這個聊天中傳送 cashu ecash 代幣\n/drop — 在這個地方釘一張便箋 24 小時(需要位置)\n/help — 這份清單" + } + } + } + }, + "command.msg.not_found" : { + "comment" : "Error when /msg can't resolve the nickname", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' nicht gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' not found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' no encontrado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' پیدا نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi nahanap ang '%@'" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' が見つからない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@'을(를) 찾을 수 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' फेला परेन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie znaleziono '%@'" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' não encontrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่พบ '%@'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "'%@' نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không tìm thấy '%@'" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 '%@'" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "找不到 '%@'" + } + } + } + }, + "command.msg.started" : { + "comment" : "Confirmation after /msg opens a private chat", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدأت محادثة خاصة مع %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর সাথে প্রাইভেট চ্যাট শুরু হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "privater chat mit %@ gestartet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "started private chat with %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat privado iniciado con %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "چت خصوصی با %@ شروع شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagsimula ng pribadong chat kay %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat privé démarré avec %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נפתח צ'אט פרטי עם %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ के साथ निजी चैट शुरू हुई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat pribadi dengan %@ dimulai" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat privata avviata con %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ とのプライベートチャットを開始した" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@와(과) 개인 채팅을 시작했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sembang peribadi dengan %@ dimulakan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ सँग निजी च्याट सुरु भयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "privéchat met %@ gestart" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "rozpoczęto prywatny czat z %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "conversa privada iniciada com %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "chat privado iniciado com %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "начат личный чат с %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "startade privat chatt med %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ உடன் தனி அரட்டை தொடங்கியது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เริ่มแชทส่วนตัวกับ %@ แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ile özel sohbet başlatıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "розпочато приватний чат з %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کے ساتھ نجی چیٹ شروع ہو گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã bắt đầu trò chuyện riêng với %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已开始与 %@ 的私聊" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已與 %@ 開始私人聊天" + } + } + } + }, + "command.msg.usage" : { + "comment" : "Usage hint for /msg", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /msg @nickname [message]" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /msg @nickname [বার্তা]" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /msg @nickname [nachricht]" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /msg @nickname [message]" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /msg @nickname [mensaje]" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /msg @nickname [message]" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /msg @nickname [mensahe]" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /msg @nickname [message]" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /msg @nickname [message]" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /msg @nickname [संदेश]" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /msg @nickname [pesan]" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /msg @nickname [messaggio]" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /msg @nickname [メッセージ]" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /msg @nickname [메시지]" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /msg @nickname [mesej]" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /msg @nickname [सन्देश]" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /msg @nickname [bericht]" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /msg @nickname [wiadomość]" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /msg @nickname [mensagem]" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /msg @nickname [mensagem]" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /msg @nickname [сообщение]" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /msg @nickname [message]" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /msg @nickname [message]" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /msg @nickname [message]" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /msg @nickname [message]" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /msg @nickname [message]" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /msg @nickname [message]" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /msg @nickname [message]" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/msg @nickname [message]" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/msg @nickname [message]" + } + } + } + }, + "command.pay.invalid" : { + "comment" : "Error when /pay input fails to decode", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رمز cashu غير صالح — لا يمكن فك ترميزه إلى رمز معروف بمبلغ، لن يتم إرساله" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অবৈধ cashu টোকেন — পরিমাণসহ কোনো পরিচিত টোকেনে ডিকোড হচ্ছে না, পাঠানো হচ্ছে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ungültiger cashu-token — er lässt sich nicht zu einem bekannten token mit betrag dekodieren, wird nicht gesendet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "invalid cashu token — it doesn't decode to a known token with an amount, not sending it" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu no válido — no se decodifica a un token conocido con un importe, no se envía" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "توکن cashu نامعتبر — به توکن شناخته‌شده‌ای با مبلغ رمزگشایی نمی‌شود، ارسال نمی‌شود" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "invalid na cashu token — hindi ito nade-decode sa kilalang token na may halaga, hindi ipapadala" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu invalide — il ne se décode pas en un token connu avec un montant, envoi annulé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טוקן cashu לא חוקי — הוא לא מפוענח לטוקן מוכר עם סכום, לא נשלח" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अमान्य cashu टोकन — यह रकम वाले किसी ज्ञात टोकन में डिकोड नहीं होता, नहीं भेजा जा रहा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu tidak valid — tidak bisa didekode jadi token dikenal dengan nominal, tidak jadi dikirim" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu non valido — non si decodifica in un token noto con un importo, non lo invio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無効な cashu トークン — 金額付きの既知のトークンとしてデコードできないので送らない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "잘못된 cashu 토큰이에요 — 금액이 있는 토큰으로 디코딩되지 않아서 보내지 않아요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu tidak sah — tidak dapat dinyahkod sebagai token yang dikenali dengan jumlah, tidak akan dihantar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अवैध cashu टोकन — रकम भएको ज्ञात टोकनमा डिकोड हुँदैन, पठाइँदैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ongeldig cashu-token — het decodeert niet naar een bekend token met een bedrag, wordt niet verzonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nieprawidłowy token cashu — nie dekoduje się do znanego tokenu z kwotą, nie wysyłam" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu inválido — não descodifica para um token conhecido com um montante, não vai ser enviado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu inválido — não decodifica para um token conhecido com um valor, não vou enviar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "неверный cashu-токен — он не декодируется в известный токен с суммой, отправки не будет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ogiltig cashu-token — den avkodas inte till en känd token med ett belopp, skickar den inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செல்லாத cashu டோக்கன் — தொகையுடன் கூடிய அறியப்பட்ட டோக்கனாக இது டிகோட் ஆகவில்லை, அதை அனுப்பவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โทเคน cashu ไม่ถูกต้อง — ถอดรหัสออกมาไม่เป็นโทเคนที่รู้จักพร้อมจำนวนเงิน จะไม่ส่งให้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "geçersiz cashu token'ı — tutarı olan bilinen bir token'a çözülemiyor, gönderilmiyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "недійсний cashu-токен — він не декодується у відомий токен із сумою, не надсилаю" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "غلط cashu ٹوکن — یہ رقم والے کسی معلوم ٹوکن میں ڈی کوڈ نہیں ہوتا، اسے نہیں بھیجا جا رہا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "token cashu không hợp lệ — không giải mã được thành token đã biết kèm số tiền, sẽ không gửi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效的 cashu 代币 — 无法解码成带金额的已知代币,不会发送" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無效的 cashu 代幣 — 無法解碼成帶金額的已知代幣,不會送出" + } + } + } + }, + "command.pay.not_token" : { + "comment" : "Error when /pay input has no cashu prefix", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هذا لا يبدو رمز cashu — المتوقع cashuA… أو cashuB…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এটা cashu টোকেনের মতো দেখাচ্ছে না — cashuA… বা cashuB… হওয়ার কথা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "das sieht nicht nach einem cashu-token aus — erwartet wird cashuA… oder cashuB…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "that doesn't look like a cashu token — expected cashuA… or cashuB…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eso no parece un token cashu — se esperaba cashuA… o cashuB…" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این شبیه توکن cashu نیست — انتظار cashuA… یا cashuB… می‌رفت" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mukhang hindi iyan cashu token — inaasahan ay cashuA… o cashuB…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ça ne ressemble pas à un token cashu — attendu : cashuA… ou cashuB…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זה לא נראה כמו טוקן cashu — אמור להיות cashuA… או cashuB…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह cashu टोकन जैसा नहीं लगता — cashuA… या cashuB… होना चाहिए" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak terlihat seperti token cashu — seharusnya cashuA… atau cashuB…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "non sembra un token cashu — atteso cashuA… o cashuB…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "cashu トークンには見えない — cashuA… か cashuB… のはず" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "cashu 토큰이 아닌 것 같아요 — cashuA… 또는 cashuB…여야 해요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "itu tidak kelihatan seperti token cashu — sepatutnya cashuA… atau cashuB…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "त्यो cashu टोकन जस्तो देखिँदैन — cashuA… वा cashuB… हुनुपर्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dat ziet er niet uit als een cashu-token — verwacht cashuA… of cashuB…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "to nie wygląda na token cashu — oczekiwano cashuA… lub cashuB…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um token cashu — esperava-se cashuA… ou cashuB…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "isso não parece um token cashu — esperado cashuA… ou cashuB…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это не похоже на cashu-токен — ожидается cashuA… или cashuB…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det där ser inte ut som en cashu-token — väntade cashuA… eller cashuB…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அது cashu டோக்கன் போல தெரியவில்லை — cashuA… அல்லது cashuB… எதிர்பார்க்கப்பட்டது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อันนั้นดูไม่เหมือนโทเคน cashu — ควรเป็น cashuA… หรือ cashuB…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu bir cashu token'ına benzemiyor — cashuA… veya cashuB… bekleniyordu" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "це не схоже на cashu-токен — очікується cashuA… або cashuB…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ cashu ٹوکن جیسا نہیں لگتا — cashuA… یا cashuB… متوقع تھا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cái đó không giống token cashu — cần dạng cashuA… hoặc cashuB…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这看起来不像 cashu 代币 — 应为 cashuA… 或 cashuB…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這看起來不像 cashu 代幣 — 應該是 cashuA… 或 cashuB…" + } + } + } + }, + "command.pay.public_confirm" : { + "comment" : "Confirmation gate before sending a cashu token to a public channel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هذه قناة عامة — أي شخص يقرأها يمكنه استبدال الرمز. للإرسال رغم ذلك: /pay public" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এটা একটা পাবলিক চ্যানেল — যে কেউ পড়ে টোকেনটা ভাঙিয়ে নিতে পারে। তবুও পাঠাতে: /pay <টোকেন> public" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dies ist ein öffentlicher kanal — alle, die mitlesen, können den token einlösen. trotzdem senden: /pay public" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "este es un canal público — cualquiera que lo lea puede canjear el token. enviar de todos modos: /pay public" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "این یک کانال عمومی است — هرکسی که آن را بخواند می‌تواند توکن را نقد کند. برای ارسال به‌هرحال: /pay public" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pampublikong channel ito — kahit sinong nakakabasa ay puwedeng mag-redeem ng token. ipadala pa rin: /pay public" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ceci est un canal public — quiconque le lit peut encaisser le token. envoyer quand même : /pay public" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זה ערוץ ציבורי — כל מי שקורא אותו יכול לפדות את הטוקן. לשלוח בכל זאת: /pay public" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यह एक सार्वजनिक चैनल है — इसे पढ़ने वाला कोई भी टोकन भुना सकता है। फिर भी भेजना हो तो: /pay <टोकन> public" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ini channel publik — siapa pun yang membacanya bisa menukarkan token itu. tetap kirim: /pay public" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "questo è un canale pubblico — chiunque lo legga può riscattare il token. invia comunque: /pay public" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここは公開チャンネル — 読める人なら誰でもトークンを換金できる。それでも送るなら: /pay <トークン> public" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기는 공개 채널이에요 — 읽는 사람 누구나 토큰을 상환할 수 있어요. 그래도 보내려면: /pay <토큰> public" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ini saluran awam — sesiapa yang membacanya boleh menebus token itu. hantar juga: /pay public" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यो सार्वजनिक च्यानल हो — पढ्ने जो कोहीले टोकन भुनाउन सक्छ। जे होस् पठाउन: /pay <टोकन> public" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dit is een openbaar kanaal — iedereen die meeleest kan het token inwisselen. toch versturen: /pay public" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "to jest kanał publiczny — każdy, kto go czyta, może wykupić token. wyślij mimo to: /pay public" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "este é um canal público — qualquer pessoa que o leia pode resgatar o token. enviar mesmo assim: /pay public" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "este é um canal público — qualquer pessoa lendo pode resgatar o token. enviar mesmo assim: /pay public" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "это публичный канал — любой, кто его читает, может обналичить токен. всё равно отправить: /pay <токен> public" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "det här är en offentlig kanal — vem som helst som läser den kan lösa in tokenen. skicka ändå: /pay public" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இது பொது சேனல் — இதை படிக்கும் யார் வேண்டுமானாலும் டோக்கனை மீட்கலாம். எப்படியும் அனுப்ப: /pay <டோக்கன்> public" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "นี่คือช่องสาธารณะ — ใครที่อ่านอยู่ก็แลกโทเคนได้ ถ้าจะส่งจริง ๆ: /pay <โทเคน> public" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "burası herkese açık bir kanal — okuyan herkes token'ı bozdurabilir. yine de göndermek için: /pay public" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "це публічний канал — будь-хто, хто його читає, може погасити токен. надіслати все одно: /pay <токен> public" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہ ایک عوامی چینل ہے — جو بھی اسے پڑھ رہا ہے وہ ٹوکن ریڈیم کر سکتا ہے۔ پھر بھی بھیجنا ہو تو: /pay <ٹوکن> public" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đây là kênh công khai — bất kỳ ai đọc được đều có thể đổi token. vẫn muốn gửi: /pay public" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这是公开频道 — 任何看到的人都能兑换这个代币。仍要发送:/pay <代币> public" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這是公開頻道 — 任何看到的人都能兌換這個代幣。仍要送出:/pay <代幣> public" + } + } + } + }, + "command.pay.sent_private" : { + "comment" : "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم إرسال %@ — cashu رمز لحامله؛ من يستبدله أولًا يحصل على المال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ পাঠানো হলো — cashu একটি বেয়ারার টোকেন; যে আগে ভাঙাবে, টাকা তারই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ gesendet — cashu ist ein inhaber-token; wer ihn zuerst einlöst, bekommt das geld" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado — cashu es un token al portador; quien lo canjee primero se queda con los fondos" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ارسال شد — cashu توکن حامل است؛ هرکس اول نقدش کند پول را می‌گیرد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naipadala ang %@ — bearer token ang cashu; ang unang maka-redeem ang makakakuha ng pondo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ envoyé — cashu est un token au porteur ; la première personne qui l'encaisse récupère les fonds" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נשלח — cashu הוא טוקן למחזיק בו; מי שפודה אותו ראשון מקבל את הכסף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ भेजा गया — cashu एक बेयरर टोकन है; जो इसे पहले भुनाएगा, पैसे उसी के" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ terkirim — cashu adalah token pembawa; siapa yang menukarkannya lebih dulu, dia yang mendapat dananya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ inviato — cashu è un token al portatore; chi lo riscatta per primo ottiene i fondi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ を送った — cashu は持参人払いのトークン。最初に換金した人が資金を受け取る" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 보냈어요 — cashu는 소지자 토큰이라 먼저 상환하는 사람이 돈을 가져가요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dihantar — cashu ialah token pembawa; sesiapa yang menebusnya dahulu mendapat dananya" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ पठाइयो — cashu वाहक टोकन हो; जसले पहिले भुनाउँछ, उसैले रकम पाउँछ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ verzonden — cashu is een token aan toonder; wie het als eerste inwisselt krijgt het geld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wysłano %@ — cashu to token na okaziciela; środki dostaje ten, kto pierwszy go wykupi" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado — o cashu é um token ao portador; quem o resgatar primeiro fica com os fundos" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado — cashu é um token ao portador; quem resgatar primeiro fica com os fundos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ отправлено — cashu — это токен на предъявителя; средства получит тот, кто первым его обналичит" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skickade %@ — cashu är en innehavartoken; den som löser in den först får pengarna" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ அனுப்பப்பட்டது — cashu வைத்திருப்பவருக்கே உரிய டோக்கன்; யார் முதலில் மீட்கிறாரோ அவருக்கே பணம் கிடைக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่ง %@ แล้ว — cashu เป็นโทเคนแบบผู้ถือ ใครแลกก่อนได้เงินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ gönderildi — cashu hamiline yazılı bir token; kim önce bozdurursa parayı o alır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надіслано %@ — cashu — це токен на пред'явника; кошти отримає той, хто першим його погасить" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ بھیج دیا گیا — cashu ایک bearer ٹوکن ہے؛ جو پہلے اسے ریڈیم کرے گا رقم اسے ملے گی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã gửi %@ — cashu là token theo người cầm giữ; ai đổi trước sẽ nhận được tiền" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已发送 %@ — cashu 是不记名代币,谁先兑换资金就归谁" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已送出 %@ — cashu 是不記名代幣,誰先兌換錢就是誰的" + } + } + } + }, + "command.pay.sent_public" : { + "comment" : "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم إرسال %@ إلى القناة العامة — يمكن لأي شخص هنا استبداله" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ পাবলিক চ্যানেলে পাঠানো হলো — এখানে যে কেউ এটা ভাঙিয়ে নিতে পারে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ in den öffentlichen kanal gesendet — alle hier können ihn einlösen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sent %@ to the public channel — anyone here can redeem it" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado al canal público — cualquiera aquí puede canjearlo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ به کانال عمومی ارسال شد — هرکسی اینجا می‌تواند نقدش کند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "naipadala ang %@ sa pampublikong channel — kahit sino rito ay puwede itong i-redeem" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ envoyé sur le canal public — tout le monde ici peut l'encaisser" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נשלח לערוץ הציבורי — כל אחד כאן יכול לפדות אותו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ सार्वजनिक चैनल पर भेजा गया — यहाँ कोई भी इसे भुना सकता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ terkirim ke channel publik — siapa pun di sini bisa menukarkannya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ inviato al canale pubblico — chiunque qui può riscattarlo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ を公開チャンネルに送った — ここにいる誰でも換金できる" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "공개 채널에 %@ 보냈어요 — 여기 있는 누구나 상환할 수 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dihantar ke saluran awam — sesiapa di sini boleh menebusnya" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ सार्वजनिक च्यानलमा पठाइयो — यहाँ भएको जो कोहीले भुनाउन सक्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ naar het openbare kanaal gestuurd — iedereen hier kan het inwisselen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wysłano %@ na kanał publiczny — każdy tutaj może go wykupić" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado para o canal público — qualquer pessoa aqui pode resgatá-lo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enviado para o canal público — qualquer pessoa aqui pode resgatar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ отправлено в публичный канал — любой здесь может его обналичить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "skickade %@ till den offentliga kanalen — vem som helst här kan lösa in den" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பொது சேனலுக்கு %@ அனுப்பப்பட்டது — இங்குள்ள யார் வேண்டுமானாலும் அதை மீட்கலாம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่ง %@ ไปยังช่องสาธารณะแล้ว — ใครในนี้ก็แลกได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ herkese açık kanala gönderildi — buradaki herkes bozdurabilir" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "надіслано %@ у публічний канал — будь-хто тут може його погасити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ عوامی چینل پر بھیج دیا گیا — یہاں کوئی بھی اسے ریڈیم کر سکتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã gửi %@ vào kênh công khai — bất kỳ ai ở đây cũng có thể đổi nó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已把 %@ 发送到公开频道 — 这里任何人都能兑换" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已把 %@ 送到公開頻道 — 這裡任何人都能兌換" + } + } + } + }, + "command.pay.usage" : { + "comment" : "Usage hint for /pay", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /pay — الصق رمز cashu: /pay cashuA…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /pay <টোকেন> — একটি cashu টোকেন পেস্ট করো: /pay cashuA…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /pay — füge einen cashu-token ein: /pay cashuA…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /pay — paste a cashu token: /pay cashuA…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /pay — pega un token cashu: /pay cashuA…" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /pay — یک توکن cashu را جای‌گذاری کن: /pay cashuA…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /pay — i-paste ang isang cashu token: /pay cashuA…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /pay — colle un token cashu : /pay cashuA…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /pay — הדביקו טוקן cashu: /pay cashuA…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /pay <टोकन> — एक cashu टोकन पेस्ट करो: /pay cashuA…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /pay — tempel token cashu: /pay cashuA…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /pay — incolla un token cashu: /pay cashuA…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /pay <トークン> — cashu トークンを貼り付けて: /pay cashuA…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /pay <토큰> — cashu 토큰을 붙여넣어요: /pay cashuA…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /pay — tampal token cashu: /pay cashuA…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /pay <टोकन> — cashu टोकन टाँस्नुहोस्: /pay cashuA…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /pay — plak een cashu-token: /pay cashuA…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /pay — wklej token cashu: /pay cashuA…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /pay — cola um token cashu: /pay cashuA…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /pay — cole um token cashu: /pay cashuA…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /pay <токен> — вставь cashu-токен: /pay cashuA…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /pay — klistra in en cashu-token: /pay cashuA…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /pay <டோக்கன்> — ஒரு cashu டோக்கனை ஒட்டவும்: /pay cashuA…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /pay <โทเคน> — วางโทเคน cashu: /pay cashuA…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /pay — bir cashu token'ı yapıştır: /pay cashuA…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /pay <токен> — встав cashu-токен: /pay cashuA…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /pay <ٹوکن> — cashu ٹوکن پیسٹ کریں: /pay cashuA…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /pay — dán một token cashu: /pay cashuA…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/pay <代币> — 粘贴一个 cashu 代币:/pay cashuA…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/pay <代幣> — 貼上 cashu 代幣:/pay cashuA…" + } + } + } + }, + "command.ping.started" : { + "comment" : "Confirmation that /ping sent a probe", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ إرسال ping إلى %@…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে ping করা হচ্ছে…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinge %@…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinging %@…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "haciendo ping a %@…" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "در حال ping به %@…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pini-ping si %@…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping de %@ en cours…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שולחים ping אל %@…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को ping किया जा रहा है…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "melakukan ping ke %@…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ping a %@…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ に ping 中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에게 ping 보내는 중…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sedang ping %@…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ लाई ping गर्दै…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ wordt gepingd…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinguję %@…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a fazer ping a %@…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fazendo ping em %@…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "пингую %@…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "pingar %@…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ க்கு ping அனுப்புகிறது…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลัง ping %@…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ için ping atılıyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "пінгуємо %@…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو ping کیا جا رہا ہے…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đang ping %@…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在 ping %@…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在 ping %@…" + } + } + } + }, + "command.trace.no_path" : { + "comment" : "Reply when /trace has no mesh path to the target", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يوجد مسار معروف إلى %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-এর কোনো জানা পথ নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kein bekannter pfad zu %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no known path to %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no hay ruta conocida hacia %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسیر شناخته‌شده‌ای به %@ وجود ندارد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang kilalang path papunta kay %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucun chemin connu vers %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתיב מוכר אל %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ तक कोई ज्ञात रास्ता नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada jalur yang diketahui ke %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessun percorso noto verso %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ への既知の経路がない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(으)로 가는 알려진 경로가 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada laluan diketahui ke %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ सम्मको कुनै ज्ञात बाटो छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen bekend pad naar %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "brak znanej ścieżki do %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum caminho conhecido até %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhum caminho conhecido até %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нет известного пути до %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen känd väg till %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ க்கு தெரிந்த பாதை இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่รู้เส้นทางไปยัง %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ için bilinen bir yol yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "немає відомого шляху до %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ تک کوئی معلوم راستہ نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không có đường đi nào đã biết tới %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有到 %@ 的已知路径" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有通往 %@ 的已知路徑" + } + } + } + }, + "command.trace.path_many" : { + "comment" : "Reply to /trace; placeholders are the node chain and hop count", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المسار المقدّر: %1$@ (%2$lld قفزات)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আনুমানিক পথ: %1$@ (%2$lld হপ)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geschätzter pfad: %1$@ (%2$lld hops)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated path: %1$@ (%2$lld hops)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ruta estimada: %1$@ (%2$lld saltos)" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسیر تخمینی: %1$@ (%2$lld هاپ)" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tinatayang path: %1$@ (%2$lld na hop)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "chemin estimé : %1$@ (%2$lld sauts)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתיב משוער: %1$@ (%2$lld הופים)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनुमानित रास्ता: %1$@ (%2$lld हॉप)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perkiraan jalur: %1$@ (%2$lld hop)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "percorso stimato: %1$@ (%2$lld hop)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推定経路: %1$@ (%2$lldホップ)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 경로: %1$@ (%2$lld홉)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "laluan anggaran: %1$@ (%2$lld hop)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनुमानित बाटो: %1$@ (%2$lld हप)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geschat pad: %1$@ (%2$lld hops)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "szacowana ścieżka: %1$@ (liczba przeskoków: %2$lld)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "caminho estimado: %1$@ (%2$lld saltos)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "caminho estimado: %1$@ (%2$lld saltos)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "примерный путь: %1$@ (хопов: %2$lld)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "uppskattad väg: %1$@ (%2$lld hopp)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மதிப்பிடப்பட்ட பாதை: %1$@ (%2$lld தாவல்கள்)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เส้นทางโดยประมาณ: %1$@ (%2$lld ฮอป)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tahmini yol: %1$@ (%2$lld atlama)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "орієнтовний шлях: %1$@ (%2$lld хопів)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تخمینی راستہ: %1$@ (%2$lld ہاپس)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đường đi ước tính: %1$@ (%2$lld chặng)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估计路径:%1$@(%2$lld 跳)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估計路徑:%1$@(%2$lld 跳)" + } + } + } + }, + "command.trace.path_one" : { + "comment" : "Reply to /trace for a single-hop path; placeholder is the node chain", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المسار المقدّر: %@ (قفزة واحدة)" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আনুমানিক পথ: %@ (১ হপ)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "geschätzter pfad: %@ (1 hop)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimated path: %@ (1 hop)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ruta estimada: %@ (1 salto)" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسیر تخمینی: %@ (۱ هاپ)" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "tinatayang path: %@ (1 hop)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "chemin estimé : %@ (1 saut)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתיב משוער: %@ (הופ אחד)" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनुमानित रास्ता: %@ (1 हॉप)" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "perkiraan jalur: %@ (1 hop)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "percorso stimato: %@ (1 hop)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推定経路: %@ (1ホップ)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 경로: %@ (1홉)" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "laluan anggaran: %@ (1 hop)" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनुमानित बाटो: %@ (1 हप)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geschat pad: %@ (1 hop)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "szacowana ścieżka: %@ (1 przeskok)" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "caminho estimado: %@ (1 salto)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "caminho estimado: %@ (1 salto)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "примерный путь: %@ (1 хоп)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "uppskattad väg: %@ (1 hopp)" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "மதிப்பிடப்பட்ட பாதை: %@ (1 தாவல்)" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เส้นทางโดยประมาณ: %@ (1 ฮอป)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tahmini yol: %@ (1 atlama)" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "орієнтовний шлях: %@ (1 хоп)" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تخمینی راستہ: %@ (1 ہاپ)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đường đi ước tính: %@ (1 chặng)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估计路径:%@(1 跳)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估計路徑:%@(1 跳)" + } + } + } + }, + "command.trace.you" : { + "comment" : "Label for the local device at the start of a /trace path", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنت" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "তুমি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "du" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "tú" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تو" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "ikaw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "toi" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "את/ה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "तुम" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kamu" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "tu" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あなた" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "나" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kamu" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तिमी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "jij" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ty" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "tu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "você" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "du" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நீ" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คุณ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sen" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ти" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你" + } + } + } + }, + "command.unblock.done" : { + "comment" : "Confirmation after unblocking a mesh peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم رفع الحظر عن %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে আনব্লক করা হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockierung von %@ aufgehoben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسدودیت %@ برداشته شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "na-unblock si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ débloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "החסימה של %@ הוסרה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ अनब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "blokir %@ dibuka" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sbloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ のブロックを解除した" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 차단을 해제했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dinyahsekat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ अनब्लक गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ gedeblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odblokowano %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ разблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "avblockerade %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ தடைநீக்கப்பட்டார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลิกบล็อก %@ แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ engeli kaldırıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ розблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو ان بلاک کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã bỏ chặn %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已解除屏蔽 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已解除封鎖 %@" + } + } + } + }, + "command.unblock.done_geo" : { + "comment" : "Confirmation after unblocking a geohash participant", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم رفع الحظر عن %@ في محادثات geohash" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash চ্যাটে %@-কে আনব্লক করা হলো" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "blockierung von %@ in geohash-chats aufgehoben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "unblocked %@ in geohash chats" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado en los chats de geohash" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسدودیت %@ در چت‌های geohash برداشته شد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "na-unblock si %@ sa mga geohash chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ débloqué dans les chats geohash" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "החסימה של %@ הוסרה בצ'אטים של geohash" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash चैट में %@ को अनब्लॉक किया गया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "blokir %@ dibuka di chat geohash" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sbloccato nelle chat geohash" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash チャットで %@ のブロックを解除した" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash 채팅에서 %@ 차단을 해제했어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ dinyahsekat dalam sembang geohash" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash च्याटहरूमा %@ अनब्लक गरियो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ gedeblokkeerd in geohash-chats" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "odblokowano %@ w czatach geohash" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado nas conversas geohash" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ desbloqueado nos chats geohash" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ разблокирован в geohash-чатах" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "avblockerade %@ i geohash-chattar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash அரட்டைகளில் %@ தடைநீக்கப்பட்டார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลิกบล็อก %@ ในแชท geohash แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash sohbetlerinde %@ engeli kaldırıldı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ розблоковано в geohash-чатах" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "geohash چیٹس میں %@ کو ان بلاک کر دیا گیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã bỏ chặn %@ trong các kênh trò chuyện geohash" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已在 geohash 聊天中解除屏蔽 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已在 geohash 聊天中解除封鎖 %@" + } + } + } + }, + "command.unblock.failed" : { + "comment" : "Error when /unblock can't resolve the target", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر رفع الحظر عن %@: غير موجود" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@-কে আনব্লক করা গেল না: পাওয়া যায়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kann %@ nicht entblocken: nicht gefunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "cannot unblock %@: not found" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se puede desbloquear a %@: no encontrado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نمی‌توان مسدودیت %@ را برداشت: پیدا نشد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi ma-unblock si %@: hindi nahanap" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de débloquer %@ : introuvable" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אי אפשר לבטל את החסימה של %@: לא נמצא" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ को अनब्लॉक नहीं कर सकते: नहीं मिला" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak bisa membuka blokir %@: tidak ditemukan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile sbloccare %@: non trovato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ のブロックを解除できない: 見つからない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 차단을 해제할 수 없어요: 찾을 수 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat menyahsekat %@: tidak dijumpai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ लाई अनब्लक गर्न सकिएन: फेला परेन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan %@ niet deblokkeren: niet gevonden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie można odblokować %@: nie znaleziono" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não é possível desbloquear %@: não encontrado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não dá para desbloquear %@: não encontrado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не получается разблокировать %@: не найден" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kan inte avblockera %@: hittades inte" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ஐ தடைநீக்க முடியாது: கிடைக்கவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลิกบล็อก %@ ไม่ได้: ไม่พบ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ engeli kaldırılamıyor: bulunamadı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося розблокувати %@: не знайдено" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ کو ان بلاک نہیں کیا جا سکا: نہیں ملا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể bỏ chặn %@: không tìm thấy" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法解除屏蔽 %@:未找到" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法解除封鎖 %@:找不到" + } + } + } + }, + "command.unblock.not_blocked" : { + "comment" : "Reply when /unblock targets a nickname that isn't blocked", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ليس محظورًا" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ব্লক করা নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ist nicht blockiert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is not blocked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ no está bloqueado" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ مسدود نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi naka-block si %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ n'est pas bloqué" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ לא חסום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ब्लॉक नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tidak diblokir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ non è bloccato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ はブロックされていない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@은(는) 차단되어 있지 않아요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tidak disekat" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ब्लक गरिएको छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ is niet geblokkeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ nie jest na liście zablokowanych" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ não está bloqueado" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ não está bloqueado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ не заблокирован" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ är inte blockerad" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ தடுக்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ไม่ได้ถูกบล็อก" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ engelli değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ не заблоковано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ بلاک نہیں ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ không bị chặn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 未被屏蔽" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 沒有被封鎖" + } + } + } + }, + "command.unblock.usage" : { + "comment" : "Usage hint for /unblock", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاستخدام: /unblock " + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্যবহার: /unblock <ডাকনাম>" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verwendung: /unblock " + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage: /unblock " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /unblock " + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کاربرد: /unblock " + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "paggamit: /unblock " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "usage : /unblock " + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שימוש: /unblock " + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपयोग: /unblock <उपनाम>" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "penggunaan: /unblock " + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /unblock " + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "使い方: /unblock <ニックネーム>" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용법: /unblock <닉네임>" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "cara guna: /unblock " + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "प्रयोग: /unblock <उपनाम>" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "gebruik: /unblock " + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "użycie: /unblock " + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /unblock " + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "uso: /unblock " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "использование: /unblock <ник>" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "användning: /unblock " + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பயன்பாடு: /unblock <புனைப்பெயர்>" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วิธีใช้: /unblock <ชื่อเล่น>" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kullanım: /unblock " + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "використання: /unblock <нікнейм>" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "استعمال: /unblock <عرفی نام>" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cách dùng: /unblock " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/unblock <昵称>" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用法:/unblock <暱稱>" + } + } + } + }, + "command.who.nobody" : { + "comment" : "Reply to /who when no context is available", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا أحد في الجوار" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশে কেউ নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "niemand in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "nobody around" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no hay nadie cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کسی این اطراف نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang tao sa paligid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "personne dans les parages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין אף אחד בסביבה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास कोई नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada orang di sekitar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessuno nei paraggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "周りに誰もいない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주변에 아무도 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada sesiapa berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपर कोही छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "niemand in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nikogo w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ninguém por perto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ninguém por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "рядом никого" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் யாரும் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่มีใครอยู่แถวนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "etrafta kimse yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "нікого поруч" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس کوئی نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không có ai quanh đây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近没有人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近沒有人" + } + } + } + }, + "command.who.none_online" : { + "comment" : "Reply to /who when nobody else is online", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا أحد آخر متصل الآن" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই মুহূর্তে আর কেউ অনলাইনে নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "gerade ist sonst niemand online" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no one else is online right now" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "nadie más está en línea ahora mismo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "الان کس دیگری آنلاین نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang ibang online ngayon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "personne d'autre n'est en ligne pour l'instant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אף אחד אחר לא מחובר כרגע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी कोई और ऑनलाइन नहीं है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada orang lain yang online sekarang" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessun altro è online in questo momento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今は他に誰もオンラインじゃない" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금은 다른 사람이 아무도 온라인이 아니에요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada orang lain dalam talian sekarang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिले अरू कोही अनलाइन छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "er is nu verder niemand online" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nikt inny nie jest teraz online" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não está mais ninguém online neste momento" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ninguém mais está online agora" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сейчас больше никого нет в сети" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen annan är online just nu" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இப்போது வேறு யாரும் ஆன்லைனில் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ตอนนี้ไม่มีคนอื่นออนไลน์อยู่" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "şu anda başka kimse çevrimiçi değil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "зараз більше нікого немає онлайн" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی کوئی اور آن لائن نہیں ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hiện không có ai khác trực tuyến" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "现在没有其他人在线" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在沒有其他人在線上" + } + } + } + }, + "command.who.online" : { + "comment" : "Reply to /who; placeholder is the list of names", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "متصلون: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অনলাইনে: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en línea: %@" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آنلاین: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "en ligne : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחוברים: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ऑनलाइन: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オンライン: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "온라인: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "dalam talian: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अनलाइन: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в сети: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "online: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஆன்லைன்: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ออนไลน์: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "çevrimiçi: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "онлайн: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آن لائن: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trực tuyến: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在线:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在線上:%@" + } + } + } + }, "content.delivery.reason.legacy_media_consent_required" : { "comment" : "Failure reason when a legacy private-media send lacks per-send consent", "extractionState" : "manual", diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index f86f2eca..c37aa3dc 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -105,7 +105,7 @@ final class CommandProcessor { @MainActor func process(_ command: String) -> CommandResult { let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) - guard let cmd = parts.first else { return .error(message: "Invalid command") } + guard let cmd = parts.first else { return .error(message: String(localized: "command.error.invalid", defaultValue: "invalid command", comment: "Error for an empty or unparseable slash command")) } let args = parts.count > 1 ? String(parts[1]) : "" // Geohash context: disable favoriting in public geohash or GeoDM @@ -133,19 +133,19 @@ final class CommandProcessor { case "/unblock": return handleUnblock(args) case "/group": - if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") } + if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.groups_mesh_only", defaultValue: "groups are only for mesh peers in #mesh", comment: "Error when a group command is used outside the mesh channel")) } return handleGroup(args) case "/fav": - if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } + if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) } return handleFavorite(args, add: true) case "/unfav": - if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } + if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) } return handleFavorite(args, add: false) case "/ping": - if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") } + if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.ping_mesh_only", defaultValue: "ping only works for mesh peers in #mesh", comment: "Error when /ping is used outside the mesh channel")) } return handlePing(args) case "/trace": - if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") } + if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.trace_mesh_only", defaultValue: "trace only works for mesh peers in #mesh", comment: "Error when /trace is used outside the mesh channel")) } return handleTrace(args) case "/pay": return handlePay(args) @@ -154,31 +154,16 @@ final class CommandProcessor { case "/help": return .success(message: Self.helpText) default: - return .error(message: "unknown command: \(cmd) — type /help for commands") + return .error(message: String(format: String(localized: "command.error.unknown", defaultValue: "unknown command: %@ — type /help for commands", comment: "Error for an unrecognized slash command; placeholder is the typed command"), locale: .current, String(cmd))) } } /// Local-only command reference, printed as a system message. The /// suggestion panel hides once arguments are typed, and typos used to /// dead-end in a bare "unknown command" — this is the way out. - static let helpText = """ - commands: - /msg @name [message] — start a private chat - /who — list who's here - /clear — clear this chat - /hug @name — send a hug - /slap @name — slap with a large trout - /block @name · /unblock @name - /fav @name · /unfav @name — favorites (mesh only) - /group create — start an encrypted group - /group invite @name · /group remove @name — manage members (creator) - /group leave · /group list — leave or list your groups - /ping @name — measure round-trip time (mesh only) - /trace @name — estimated mesh path (mesh only) - /pay — send a cashu ecash token in this chat - /drop — pin a note to this place for 24h (needs location) - /help — this list - """ + static var helpText: String { + String(localized: "command.help.text", defaultValue: "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay — send a cashu ecash token in this chat\n/drop — pin a note to this place for 24h (needs location)\n/help — this list", comment: "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated") + } /// /drop — a dead drop: pins a note to the current building-level /// geohash with a 24h NIP-40 expiry. Anyone who passes through here and @@ -186,28 +171,28 @@ final class CommandProcessor { /// reads it. private func handleDrop(_ args: String) -> CommandResult { guard LocationNotesSettings.enabled else { - return .error(message: "location notes are off — enable them in the info screen") + return .error(message: String(localized: "command.drop.notes_off", defaultValue: "location notes are off — enable them in the info screen", comment: "Error when /drop is used while location notes are disabled")) } guard let content = args.trimmedOrNilIfEmpty else { - return .error(message: "usage: /drop ") + return .error(message: String(localized: "command.drop.usage", defaultValue: "usage: /drop ", comment: "Usage hint for /drop")) } let location = LocationChannelManager.shared guard location.permissionState == .authorized else { - return .error(message: "leaving a note needs location — enable it in the info screen") + return .error(message: String(localized: "command.drop.needs_location", defaultValue: "leaving a note needs location — enable it in the info screen", comment: "Error when /drop is used without location permission")) } guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else { location.refreshChannels() - return .error(message: "still finding this place — try again in a moment") + return .error(message: String(localized: "command.drop.finding_place", defaultValue: "still finding this place — try again in a moment", comment: "Error when /drop runs before a location fix arrives")) } guard let nickname = contextProvider?.nickname, LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else { - return .error(message: "no geo relays reachable — note not left") + return .error(message: String(localized: "command.drop.no_relays", defaultValue: "no geo relays reachable — note not left", comment: "Error when /drop finds no reachable geo relays")) } // Leaving a note is an explicit notes act: it unlocks the passive // nearby-notes counter (tap-to-reveal) so the sender sees their own // drop counted on the timeline. NearbyNotesCounter.shared.reveal() - return .success(message: "📍 note left here — it fades in 24h") + return .success(message: String(localized: "command.drop.left", defaultValue: "📍 note left here — it fades in 24h", comment: "Confirmation after /drop pins a note")) } // MARK: - Command Handlers @@ -215,14 +200,14 @@ final class CommandProcessor { private func handleMessage(_ args: String) -> CommandResult { let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false) guard !parts.isEmpty else { - return .error(message: "usage: /msg @nickname [message]") + return .error(message: String(localized: "command.msg.usage", defaultValue: "usage: /msg @nickname [message]", comment: "Usage hint for /msg")) } let targetName = String(parts[0]) let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { - return .error(message: "'\(nickname)' not found") + return .error(message: String(format: String(localized: "command.msg.not_found", defaultValue: "'%@' not found", comment: "Error when /msg can't resolve the nickname"), locale: .current, nickname)) } contextProvider?.startPrivateChat(with: peerID) @@ -232,7 +217,7 @@ final class CommandProcessor { contextProvider?.sendPrivateMessage(message, to: peerID) } - return .success(message: "started private chat with \(nickname)") + return .success(message: String(format: String(localized: "command.msg.started", defaultValue: "started private chat with %@", comment: "Confirmation after /msg opens a private chat"), locale: .current, nickname)) } private func handleWho() -> CommandResult { @@ -240,22 +225,22 @@ final class CommandProcessor { switch contextProvider?.activeChannel ?? .mesh { case .location(let ch): // Geohash context: show visible geohash participants (exclude self) - guard let vm = contextProvider else { return .success(message: "nobody around") } + guard let vm = contextProvider else { return .success(message: String(localized: "command.who.nobody", defaultValue: "nobody around", comment: "Reply to /who when no context is available")) } let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() let people = vm.getVisibleGeoParticipants().filter { person in if let me = myHex { return person.id.lowercased() != me } return true } let names = people.map { $0.displayName } - if names.isEmpty { return .success(message: "no one else is online right now") } - return .success(message: "online: " + names.sorted().joined(separator: ", ")) + if names.isEmpty { return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) } + return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, names.sorted().joined(separator: ", "))) case .mesh: // Mesh context: show connected peer nicknames guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { - return .success(message: "no one else is online right now") + return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) } let onlineList = peers.values.sorted().joined(separator: ", ") - return .success(message: "online: \(onlineList)") + return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, onlineList)) } } @@ -271,14 +256,14 @@ final class CommandProcessor { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: "usage: /\(command) ") + return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command)) } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname), let myNickname = contextProvider?.nickname else { - return .error(message: "cannot \(command) \(nickname): not found") + return .error(message: String(format: String(localized: "command.action.not_found", defaultValue: "cannot %1$@ %2$@: not found", comment: "Error when an action command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname)) } let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *" @@ -345,7 +330,7 @@ final class CommandProcessor { let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ") let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ") - return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)") + return .success(message: String(format: String(localized: "command.block.list", defaultValue: "blocked peers: %1$@ | geohash blocks: %2$@", comment: "Reply to /block with no argument; placeholders are the mesh and geohash block lists"), locale: .current, meshList, geoList)) } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName @@ -353,7 +338,7 @@ final class CommandProcessor { if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if identityManager.isBlocked(fingerprint: fingerprint) { - return .success(message: "\(nickname) is already blocked") + return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname)) } // Block the user (mesh/noise identity) if var identity = identityManager.getSocialIdentity(for: fingerprint) { @@ -375,24 +360,24 @@ final class CommandProcessor { // Scrub their carried public messages now, while the peerID is // resolvable, so they can't resurface as archived echoes. meshArchive?.purgeArchivedPublicMessages(from: peerID) - return .success(message: "blocked \(nickname). you will no longer receive messages from them") + return .success(message: String(format: String(localized: "command.block.done", defaultValue: "blocked %@. you will no longer see their messages", comment: "Confirmation after blocking a mesh peer"), locale: .current, nickname)) } // Mesh lookup failed; try geohash (Nostr) participant by display name if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { - return .success(message: "\(nickname) is already blocked") + return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname)) } identityManager.setNostrBlocked(pub, isBlocked: true) - return .success(message: "blocked \(nickname) in geohash chats") + return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname)) } - return .error(message: "cannot block \(nickname): not found or unable to verify identity") + return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname)) } private func handleUnblock(_ args: String) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: "usage: /unblock ") + return .error(message: String(localized: "command.unblock.usage", defaultValue: "usage: /unblock ", comment: "Usage hint for /unblock")) } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName @@ -400,23 +385,23 @@ final class CommandProcessor { if let peerID = contextProvider?.getPeerIDForNickname(nickname), let fingerprint = meshService?.getFingerprint(for: peerID) { if !identityManager.isBlocked(fingerprint: fingerprint) { - return .success(message: "\(nickname) is not blocked") + return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname)) } identityManager.setBlocked(fingerprint, isBlocked: false) - return .success(message: "unblocked \(nickname)") + return .success(message: String(format: String(localized: "command.unblock.done", defaultValue: "unblocked %@", comment: "Confirmation after unblocking a mesh peer"), locale: .current, nickname)) } // Try geohash unblock if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) { if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) { - return .success(message: "\(nickname) is not blocked") + return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname)) } identityManager.setNostrBlocked(pub, isBlocked: false) - return .success(message: "unblocked \(nickname) in geohash chats") + return .success(message: String(format: String(localized: "command.unblock.done_geo", defaultValue: "unblocked %@ in geohash chats", comment: "Confirmation after unblocking a geohash participant"), locale: .current, nickname)) } - return .error(message: "cannot unblock \(nickname): not found") + return .error(message: String(format: String(localized: "command.unblock.failed", defaultValue: "cannot unblock %@: not found", comment: "Error when /unblock can't resolve the target"), locale: .current, nickname)) } - private static let groupUsage = "usage: /group create · invite @name · remove @name · leave · list" + private static var groupUsage: String { String(localized: "command.group.usage", defaultValue: "usage: /group create · invite @name · remove @name · leave · list", comment: "Usage hint for /group subcommands") } private func handleGroup(_ args: String) -> CommandResult { let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) @@ -454,12 +439,12 @@ final class CommandProcessor { private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution { let targetName = args.trimmed guard !targetName.isEmpty else { - return .failed(.error(message: "usage: /\(command) ")) + return .failed(.error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command))) } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let peerID = contextProvider?.getPeerIDForNickname(nickname), !peerID.isGeoDM, !peerID.isGeoChat else { - return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh")) + return .failed(.error(message: String(format: String(localized: "command.action.not_found_mesh", defaultValue: "cannot %1$@ %2$@: not found on mesh", comment: "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname))) } return .resolved(peerID: peerID, nickname: nickname) } @@ -488,7 +473,7 @@ final class CommandProcessor { } ?? "" provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination) } - return .success(message: "pinging \(nickname)…") + return .success(message: String(format: String(localized: "command.ping.started", defaultValue: "pinging %@…", comment: "Confirmation that /ping sent a probe"), locale: .current, nickname)) } private func handleTrace(_ args: String) -> CommandResult { @@ -500,16 +485,20 @@ final class CommandProcessor { guard let mesh = meshService, let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else { - return .success(message: "no known path to \(target.nickname)") + return .success(message: String(format: String(localized: "command.trace.no_path", defaultValue: "no known path to %@", comment: "Reply when /trace has no mesh path to the target"), locale: .current, target.nickname)) } // Graph-derived from gossiped neighbor claims, not route-recorded — // present it as an estimate. let hopNames = intermediates.map { hop in mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…" } - let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ") + let you = String(localized: "command.trace.you", defaultValue: "you", comment: "Label for the local device at the start of a /trace path") + let chain = ([you] + hopNames + [target.nickname]).joined(separator: " → ") let hops = intermediates.count + 1 - return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))") + let pathMessage = hops == 1 + ? String(format: String(localized: "command.trace.path_one", defaultValue: "estimated path: %@ (1 hop)", comment: "Reply to /trace for a single-hop path; placeholder is the node chain"), locale: .current, chain) + : String(format: String(localized: "command.trace.path_many", defaultValue: "estimated path: %1$@ (%2$lld hops)", comment: "Reply to /trace; placeholders are the node chain and hop count"), locale: .current, chain, hops) + return .success(message: pathMessage) } /// `/pay ` — validates the token decodes, then sends it as @@ -520,44 +509,44 @@ final class CommandProcessor { private func handlePay(_ args: String) -> CommandResult { var parts = args.trimmed.split(separator: " ").map(String.init) guard !parts.isEmpty else { - return .success(message: "usage: /pay — paste a cashu token: /pay cashuA…") + return .success(message: String(localized: "command.pay.usage", defaultValue: "usage: /pay — paste a cashu token: /pay cashuA…", comment: "Usage hint for /pay")) } let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public" if confirmedPublic { parts.removeLast() } guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else { - return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…") + return .error(message: String(localized: "command.pay.not_token", defaultValue: "that doesn't look like a cashu token — expected cashuA… or cashuB…", comment: "Error when /pay input has no cashu prefix")) } guard let info = CashuTokenDecoder.decode(token, strict: true) else { - return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it") + return .error(message: String(localized: "command.pay.invalid", defaultValue: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it", comment: "Error when /pay input fails to decode")) } let summary = info.displayAmount ?? "a cashu token" if let peerID = contextProvider?.selectedPrivateChatPeer { contextProvider?.sendPrivateMessage(token, to: peerID) - return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds") + return .success(message: String(format: String(localized: "command.pay.sent_private", defaultValue: "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds", comment: "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary"), locale: .current, summary)) } guard confirmedPublic else { - return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public") + return .error(message: String(localized: "command.pay.public_confirm", defaultValue: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay public", comment: "Confirmation gate before sending a cashu token to a public channel")) } contextProvider?.sendPublicMessage(token) - return .success(message: "sent \(summary) to the public channel — anyone here can redeem it") + return .success(message: String(format: String(localized: "command.pay.sent_public", defaultValue: "sent %@ to the public channel — anyone here can redeem it", comment: "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary"), locale: .current, summary)) } private func handleFavorite(_ args: String, add: Bool) -> CommandResult { let targetName = args.trimmed guard !targetName.isEmpty else { - return .error(message: "usage: /\(add ? "fav" : "unfav") ") + return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ ", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, (add ? "fav" : "unfav"))) } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else { - return .error(message: "can't find peer: \(nickname)") + return .error(message: String(format: String(localized: "command.fav.not_found", defaultValue: "can't find peer: %@", comment: "Error when /fav or /unfav can't resolve the nickname"), locale: .current, nickname)) } // Resolve current state by the peer's real noise key. The resolved @@ -571,13 +560,17 @@ final class CommandProcessor { } guard add != isCurrentlyFavorite else { - return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite") + return .success(message: add + ? String(format: String(localized: "command.fav.already", defaultValue: "%@ is already a favorite", comment: "Reply when /fav targets an existing favorite"), locale: .current, nickname) + : String(format: String(localized: "command.fav.not_favorite", defaultValue: "%@ is not a favorite", comment: "Reply when /unfav targets someone who isn't a favorite"), locale: .current, nickname)) } // toggleFavorite persists by the real noise key and notifies the peer. contextProvider?.toggleFavorite(peerID: peerID) - return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites") + return .success(message: add + ? String(format: String(localized: "command.fav.added", defaultValue: "added %@ to favorites", comment: "Confirmation after /fav"), locale: .current, nickname) + : String(format: String(localized: "command.fav.removed", defaultValue: "removed %@ from favorites", comment: "Confirmation after /unfav"), locale: .current, nickname)) } } diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index e2d9d561..232aa265 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -255,7 +255,9 @@ struct CommandProcessorTests { } switch blockResult { case .success(let message): - #expect(message == "blocked bob. you will no longer receive messages from them") + // "no longer see" — blocking filters at display time; packets still + // arrive and relay, so "receive" overpromised (UX audit fix). + #expect(message == "blocked bob. you will no longer see their messages") default: Issue.record("Expected success result") } From 9b84b361225facd8e623f25d76f889d3dc54a879 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:40:14 +0200 Subject: [PATCH 14/14] Localize the last hardcoded UI strings; wire up two dead translations (#1656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/Localizable.xcstrings | 1860 +++++++++++++++++ bitchat/Services/NotificationService.swift | 6 +- .../ViewModels/ChatLifecycleCoordinator.swift | 2 +- .../ChatMediaTransferCoordinator.swift | 4 +- .../ViewModels/VoiceRecordingViewModel.swift | 12 +- bitchat/Views/ContentComposerView.swift | 2 +- bitchat/Views/ContentSheetViews.swift | 2 +- bitchat/Views/ContentView.swift | 2 +- .../Views/Image Viewers/ImagePickerView.swift | 2 +- .../Image Viewers/MacImagePickerView.swift | 2 +- 10 files changed, 1878 insertions(+), 16 deletions(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index f7002d0c..e498a20e 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -13393,6 +13393,1866 @@ } } }, + "image_picker.none_selected" : { + "comment" : "Placeholder shown in the image picker before a selection", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم يتم اختيار أي صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কোনো ছবি নির্বাচন করা হয়নি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "kein bild ausgewählt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no image selected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ninguna imagen seleccionada" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "هیچ تصویری انتخاب نشده" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang napiling larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucune image sélectionnée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא נבחרה תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कोई तस्वीर नहीं चुनी गई" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada gambar yang dipilih" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessuna immagine selezionata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像が選択されていません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택된 이미지가 없어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada imej dipilih" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "कुनै तस्बिर चयन गरिएको छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen afbeelding geselecteerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie wybrano obrazu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhuma imagem selecionada" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "nenhuma imagem selecionada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "изображение не выбрано" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen bild vald" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படம் எதுவும் தேர்ந்தெடுக்கப்படவில்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่ได้เลือกรูปภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "görsel seçilmedi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "зображення не вибрано" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کوئی تصویر منتخب نہیں کی گئی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chưa chọn ảnh nào" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未选择图片" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未選擇圖片" + } + } + } + }, + "notification.nearby.body_many" : { + "comment" : "Body of the nearby notification; placeholder is the peer count", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld أشخاص بالجوار" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশে %lld জন আছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personen in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld people around" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personas cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld نفر در اطراف هستند" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld tao sa paligid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personnes dans les parages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld אנשים בסביבה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास %lld लोग हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld orang di sekitar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld persone nei dintorni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くに%lld人います" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처에 %lld명이 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld orang di sekitar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपर %lld जना छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld mensen in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld osób w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pessoas por perto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pessoas por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "людей рядом: %lld" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personer i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் %lld பேர் இருக்கிறார்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี %lld คนอยู่แถวนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "etrafta %lld kişi var" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "людей поруч: %lld" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس %lld لوگ ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có %lld người ở gần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近有 %lld 个人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近有 %lld 個人" + } + } + } + }, + "notification.nearby.body_one" : { + "comment" : "Body of the nearby notification for exactly one peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "شخص واحد بالجوار" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আশেপাশে 1 জন আছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 person in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 person around" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 persona cerca" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 نفر در اطراف است" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 tao sa paligid" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 personne dans les parages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אדם אחד בסביבה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आस-पास 1 व्यक्ति है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 orang di sekitar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 persona nei dintorni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くに1人います" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처에 1명이 있어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 orang di sekitar" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "वरपर 1 जना छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 persoon in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 osoba w pobliżu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 pessoa por perto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 pessoa por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 человек рядом" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 person i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் 1 பேர் இருக்கிறார்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี 1 คนอยู่แถวนี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "etrafta 1 kişi var" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 людина поруч" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آس پاس 1 شخص ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có 1 người ở gần" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近有 1 个人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "附近有 1 個人" + } + } + } + }, + "notification.nearby.title" : { + "comment" : "Title of the local notification when mesh peers come into range", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 أهل bitchat بالقرب منك!" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 কাছেই bitchat-এর লোকজন!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatter in der nähe!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatters nearby!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 ¡bitchateros cerca!" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 بیت‌چتی‌ها همین نزدیکی‌اند!" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 may mga bitchatter sa malapit!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 des bitchatteurs dans le coin !" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 ביטצ'אטרים בסביבה!" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 पास में bitchat वाले लोग!" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 ada anak bitchat di sekitar!" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatter nei paraggi!" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 近くにbitchat仲間がいるよ!" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 근처에 bitchat 친구들이 있어요!" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 ada geng bitchat berdekatan!" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 नजिकै bitchat का मान्छेहरू!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatters in de buurt!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatterzy w pobliżu!" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatters por perto!" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchatters por perto!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 битчаттеры рядом!" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 bitchattare i närheten!" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 அருகிலேயே bitchat ஆட்கள்!" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 มีชาว bitchat อยู่ใกล้ๆ!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 yakınlarda bitchat'çiler var!" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 бітчатери поруч!" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 قریب ہی bitchat والے موجود ہیں!" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 có dân bitchat ở gần đây!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 附近有 bitchat 小伙伴!" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "👥 附近有 bitchat 夥伴!" + } + } + } + }, + "system.screenshot.you" : { + "comment" : "Local system line after your screenshot notice was sent to the DM peer", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التقطت لقطة شاشة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "তুমি একটি স্ক্রিনশট নিয়েছ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "du hast einen screenshot gemacht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "you took a screenshot" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hiciste una captura de pantalla" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اسکرین‌شات گرفتی" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumuha ka ng screenshot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tu as fait une capture d'écran" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "צילמת את המסך" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "तुमने स्क्रीनशॉट लिया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kamu mengambil tangkapan layar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "hai fatto uno screenshot" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スクリーンショットを撮りました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "스크린샷을 찍었어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "anda mengambil tangkapan skrin" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तिमीले स्क्रिनसट लियौ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "je hebt een screenshot gemaakt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zrobiłeś(-aś) zrzut ekranu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiraste uma captura de ecrã" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "você tirou uma captura de tela" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ты сделал(а) скриншот" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "du tog en skärmbild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நீங்கள் ஸ்கிரீன்ஷாட் எடுத்தீர்கள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "คุณแคปหน้าจอ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ekran görüntüsü aldın" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ти зробив(-ла) знімок екрана" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم نے اسکرین شاٹ لیا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bạn vừa chụp màn hình" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你截屏了" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你截圖了" + } + } + } + }, + "voice.error.mic_permission" : { + "comment" : "Alert message when the microphone permission is denied", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يلزم الوصول إلى الميكروفون لتسجيل الرسائل الصوتية." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ভয়েস নোট রেকর্ড করতে মাইক্রোফোন অ্যাক্সেস প্রয়োজন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mikrofonzugriff wird benötigt, um sprachnotizen aufzunehmen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "microphone access is required to record voice notes." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se necesita acceso al micrófono para grabar notas de voz." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "برای ضبط پیام‌های صوتی به دسترسی میکروفون نیاز است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kailangan ng access sa mikropono para makapag-record ng voice notes." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "l'accès au micro est nécessaire pour enregistrer des notes vocales." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נדרשת גישה למיקרופון כדי להקליט הודעות קוליות." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वॉइस नोट्स रिकॉर्ड करने के लिए माइक्रोफ़ोन की अनुमति ज़रूरी है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses mikrofon diperlukan untuk merekam pesan suara." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "l'accesso al microfono è necessario per registrare note vocali." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボイスメモを録音するにはマイクへのアクセスが必要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음성 메모를 녹음하려면 마이크 접근 권한이 필요해요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "akses mikrofon diperlukan untuk merakam nota suara." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "भ्वाइस नोट रेकर्ड गर्न माइक्रोफोन पहुँच आवश्यक छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "microfoontoegang is nodig om spraakmemo's op te nemen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dostęp do mikrofonu jest wymagany, aby nagrywać wiadomości głosowe." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "é preciso acesso ao microfone para gravar notas de voz." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "o acesso ao microfone é necessário para gravar notas de voz." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "для записи голосовых сообщений нужен доступ к микрофону." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mikrofonåtkomst krävs för att spela in röstmeddelanden." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குரல் குறிப்புகளை பதிவு செய்ய மைக்ரோஃபோன் அணுகல் தேவை." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ต้องใช้สิทธิ์เข้าถึงไมโครโฟนเพื่ออัดข้อความเสียง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "sesli not kaydetmek için mikrofon erişimi gerekiyor." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "для запису голосових повідомлень потрібен доступ до мікрофона." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "وائس نوٹس ریکارڈ کرنے کے لیے مائیکروفون تک رسائی درکار ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cần quyền truy cập micro để ghi âm tin nhắn thoại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录制语音消息需要麦克风权限。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錄製語音訊息需要麥克風權限。" + } + } + } + }, + "voice.error.save_failed" : { + "comment" : "Alert message when a finished voice note cannot be saved", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر حفظ التسجيل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রেকর্ডিং সংরক্ষণ করা যায়নি।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aufnahme konnte nicht gespeichert werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording failed to save." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo guardar la grabación." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ضبط ذخیره نشد." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi na-save ang recording." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "l'enregistrement n'a pas pu être sauvegardé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שמירת ההקלטה נכשלה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिकॉर्डिंग सेव नहीं हो सकी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "rekaman gagal disimpan." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile salvare la registrazione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "録音を保存できませんでした。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹음을 저장하지 못했어요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "rakaman gagal disimpan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रेकर्डिङ सेभ गर्न सकिएन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "de opname kon niet worden opgeslagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się zapisać nagrania." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível guardar a gravação." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível salvar a gravação." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось сохранить запись." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inspelningen kunde inte sparas." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பதிவை சேமிக்க முடியவில்லை." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บันทึกเสียงที่อัดไว้ไม่สำเร็จ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kayıt kaydedilemedi." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося зберегти запис." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریکارڈنگ محفوظ نہیں ہو سکی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không lưu được bản ghi âm." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录音保存失败。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錄音儲存失敗。" + } + } + } + }, + "voice.error.start_failed" : { + "comment" : "Alert message when the recorder fails to start", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعذّر بدء التسجيل." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রেকর্ডিং শুরু করা যায়নি।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aufnahme konnte nicht gestartet werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "could not start recording." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "no se pudo iniciar la grabación." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ضبط شروع نشد." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "hindi masimulan ang pag-record." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossible de démarrer l'enregistrement." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן היה להתחיל את ההקלטה." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिकॉर्डिंग शुरू नहीं हो सकी।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat memulai perekaman." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impossibile avviare la registrazione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "録音を開始できませんでした。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹음을 시작할 수 없어요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak dapat memulakan rakaman." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रेकर्डिङ सुरु गर्न सकिएन।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "kon de opname niet starten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie udało się rozpocząć nagrywania." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível iniciar a gravação." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "não foi possível iniciar a gravação." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "не удалось начать запись." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kunde inte starta inspelningen." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பதிவை தொடங்க முடியவில்லை." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เริ่มอัดเสียงไม่ได้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kayıt başlatılamadı." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "не вдалося почати запис." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریکارڈنگ شروع نہیں ہو سکی۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không thể bắt đầu ghi âm." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法开始录音。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開始錄音。" + } + } + } + }, + "voice.error.title" : { + "comment" : "Title of the voice recording error alert", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطأ في التسجيل" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রেকর্ডিং ত্রুটি" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aufnahmefehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording error" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "error de grabación" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطای ضبط" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "error sa pag-record" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "erreur d'enregistrement" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שגיאת הקלטה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिकॉर्डिंग त्रुटि" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "kesalahan perekaman" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "errore di registrazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "録音エラー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹음 오류" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ralat rakaman" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रेकर्डिङ त्रुटि" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "opnamefout" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "błąd nagrywania" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "erro de gravação" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "erro de gravação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ошибка записи" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inspelningsfel" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒலிப்பதிவு பிழை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การอัดเสียงผิดพลาด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kayıt hatası" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "помилка запису" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریکارڈنگ میں خرابی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lỗi ghi âm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录音出错" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錄音錯誤" + } + } + } + }, + "voice.error.too_short" : { + "comment" : "Alert message when a voice note is released too quickly to save", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التسجيل قصير جدًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রেকর্ডিং খুব ছোট।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "aufnahme ist zu kurz." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "recording is too short." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "la grabación es demasiado corta." + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ضبط خیلی کوتاه است." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "masyadong maikli ang recording." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "l'enregistrement est trop court." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההקלטה קצרה מדי." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिकॉर्डिंग बहुत छोटी है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "rekaman terlalu pendek." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "la registrazione è troppo corta." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "録音が短すぎます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "녹음이 너무 짧아요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "rakaman terlalu pendek." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रेकर्डिङ धेरै छोटो छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "de opname is te kort." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nagranie jest za krótkie." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a gravação é demasiado curta." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "a gravação é muito curta." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "запись слишком короткая." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inspelningen är för kort." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பதிவு மிகவும் குறுகியது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เสียงที่อัดสั้นเกินไป" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kayıt çok kısa." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "запис надто короткий." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریکارڈنگ بہت مختصر ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bản ghi âm quá ngắn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "录音太短了。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錄音太短了。" + } + } + } + }, "content.delivery.reason.legacy_media_consent_required" : { "comment" : "Failure reason when a legacy private-media send lacks per-send consent", "extractionState" : "manual", diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index dc8ef193..e50d4da5 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -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" diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index d6684e8e..ea90e692 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -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, diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index fe314f62..548e48b0 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -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 } diff --git a/bitchat/ViewModels/VoiceRecordingViewModel.swift b/bitchat/ViewModels/VoiceRecordingViewModel.swift index 727fa39a..adb7d92a 100644 --- a/bitchat/ViewModels/VoiceRecordingViewModel.swift +++ b/bitchat/ViewModels/VoiceRecordingViewModel.swift @@ -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") ) } } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 356c4141..b7deab21 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -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) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index cf62debb..88be040f 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -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") { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 48d353c3..db7b0712 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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") { diff --git a/bitchat/Views/Image Viewers/ImagePickerView.swift b/bitchat/Views/Image Viewers/ImagePickerView.swift index defc81e4..d4aa9cfe 100644 --- a/bitchat/Views/Image Viewers/ImagePickerView.swift +++ b/bitchat/Views/Image Viewers/ImagePickerView.swift @@ -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 } } diff --git a/bitchat/Views/Image Viewers/MacImagePickerView.swift b/bitchat/Views/Image Viewers/MacImagePickerView.swift index 1b88c7f2..2f467d6c 100644 --- a/bitchat/Views/Image Viewers/MacImagePickerView.swift +++ b/bitchat/Views/Image Viewers/MacImagePickerView.swift @@ -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 } }