From 020de9651956f01391ca0ede5316e3ddfcf0e776 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:45:38 +0200 Subject: [PATCH] Mesh bridging: stitch nearby mesh islands over the internet, courier drops, unified settings (#1412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Mesh bridging: stitch nearby mesh islands over Nostr, courier drops, settings surface Three features plus a UI consolidation, all opt-in behind a new Bridge toggle: Channel bridge: while bridging, outgoing public mesh messages are also signed (with a derived, unlinkable per-cell Nostr identity) as kind-20000 events tagged #r with the local geohash-6 cell and published to the cell's deterministic geo relays; mesh-only peers deposit via new toBridge/fromBridge carrier directions through a bridge gateway (bridge + gateway toggles). Remote islands' events render into the mesh timeline marked with a network glyph. Events carry the original mesh message ID so the store's insert-by-ID absorbs radio/bridge duplicates in either order. Loop prevention mirrors GatewayService (three BoundedIDSet caches + skip-if-seen-locally + budgets). Nothing crosses a bridge unless its author signed it for the bridge; a per-message "nearby only" composer toggle keeps a message radio-only. Courier over the bridge: sealed courier envelopes park on default relays as kind-1401 drops tagged #x with their day-rotating recipient tag (NIP-40 expiry), signed by per-drop throwaway keys. Recipients subscribe for their own candidate tags; bridge gateways watch verified local peers' tags and hand matching drops over as directed courier packets. DM delivery to known peers stops requiring a physical courier encounter; the Noise-X seal never opens in transit. Presence: kind-20001 heartbeats on the rendezvous feed a "people across the bridge" count in the header (approximate: local participants subtracted by radio-copy attribution). Settings/Info: AppInfoView is now a segmented Settings/Info sheet. Settings hosts appearance, voice (fixes the duplicated Voice section), a Connectivity section (bridge + gateway + Tor toggles, the latter two moved out of the location sheet), and a confirmed panic-wipe button. New announce TLV 0x06 advertises the gateway's rendezvous cell; PeerCapabilities gains .bridge. i18n: 23 new keys across all 29 locales; coverage tests green. Tests: 50 new app tests + 3 BitFoundation tests; full suite 1445 green. Co-Authored-By: Claude Fable 5 * Settings polish: one toggle style, sticky Info-first tab, location access in Settings - The live-voice toggle now uses the same settings card + IRC pill as the connectivity toggles (settingToggle, renamed from connectivityToggle). - Segmented control orders Info first; the selected pane persists across opens (AppStorage), so first-ever open lands on Info and afterwards the sheet reopens where it was left. - "remove location access" moved from the channels sheet into the Settings Connectivity section (same key, still deep-links to system settings). Co-Authored-By: Claude Fable 5 * Location UX + copy: state-aware access control, honest empty state, clearer bridge/gateway text - Settings' location control now covers all three permission states: grant (real prompt, only possible while never-asked), open-system-settings when denied, remove-access when granted. The channels sheet keeps its own grant path for people who start there. - The channels list no longer spins forever without permission; it shows "grant location access to find nearby channels" instead (new key, 29 locales). - Bridge and gateway subtitles rewritten for clarity; the gateway subtitle moved to a new key since it now carries bridge traffic, and the old geohash-only key is deleted. The word "user" is banned from copy in every locale ("this person is blocked"). Co-Authored-By: Claude Fable 5 * Field-test fixes: dedupe relay-connectivity triggers, throttle presence, log bridge decisions First on-device run confirmed publishes accepted and the subscription delivering, but exposed trigger spam: NostrRelayManager's isConnected re-emits per relay recompute, so presence published 5x/second and the courier-drop subscription rebuilt 6x in 300ms. removeDuplicates() on the sinks + a 30s presence throttle (same-second heartbeats are byte-identical events anyway). Also: injection/skip/downlink now log under 🌉 so field verification is observable — the first test looked silent precisely because dedup correctly suppressed same-island bridged copies. Co-Authored-By: Claude Fable 5 * Fix restart self-echo: recognize own rendezvous events by derived pubkey Second field run proved bidirectional bridging live (~1-2s Mac<->iPhone via Tor) but caught a bug: relay backfill after an app relaunch re-delivered the device's own pre-restart events, and with the in-memory published-ID cache wiped they rendered as bridged copies of your own messages. The rendezvous identity is deterministically derived per cell, so self-recognition by pubkey needs no cache and survives restarts; own events are also marked never-downlink. Regression test simulates the fresh-launch state. Co-Authored-By: Claude Fable 5 * People sheet: mesh, bridge, and groups in one list The header's bridged count had no matching faces anywhere — the people sheet only knew mesh peers. BridgeService now publishes named participants (nickname from message tags, geohash-style #last4 disambiguation, presence keeps a known name alive) and the mesh people sheet gains an "across the bridge" section between mesh peers and groups. Display-only rows in v1 (bridged identities have no DM route yet). Two new catalog keys x29 locales; also normalizes one out-of-sort-order entry inherited from a hand-edited key on main. Co-Authored-By: Claude Fable 5 * Header: one people icon, one count, one sheet Fold the bridged-people count into the main person.2.fill count instead of a second network-glyph counter; the merged people sheet (mesh / across the bridge / groups) is the breakdown. VoiceOver still announces how many of the total are across the bridge. Co-Authored-By: Claude Fable 5 * People sheet symmetry: #mesh section header, no active count Every section now gets the same glyph+label header shape (shared PeopleSectionHeader): #mesh over the peer list, across-the-bridge over bridged people. The "N active" line is gone (mesh); location channels keep their geohash subtitle. Dead subtitle/count helpers removed. Co-Authored-By: Claude Fable 5 * One switch: the bridge toggle drives all internet sharing Field feedback: two toggles (bridge + gateway) with an invisible dependency was a trap — bridged messages silently never reached mesh-only neighbors unless a second lever was found and flipped. Collapsed to a single switch that does the right thing for your situation: - Bridge ON + internet: your messages cross, you see the bridge, AND your device serves its island — accepts toBridge deposits, carries remote messages onto the radio, watches courier drops for verified local peers, advertises the cell, and runs the geohash-channel gateway. - Bridge ON, no internet: you ride whoever nearby is serving. - Bridge OFF: nothing of yours crosses; radio reception of bridged traffic stays passive and free. With every online bridger serving, downlink gets a 0.2-1.5s jittered holdoff + send-time suppression recheck so co-located gateways don't burn duplicate airtime (two-gateway test included). The internet-gateway card is gone from Settings (GatewayService now follows the bridge switch, with launch-time migration); its orphaned catalog keys deleted and the bridge subtitle broadened across all 29 locales. Also: MeshPeerList's empty state ("nobody around...") aligned to the section row rhythm. Co-Authored-By: Claude Fable 5 * Bridge pumps its own location; fix centered people sheet; stop drop-subscription churn Field session 3 found the bridge silently cell-less: it read availableChannels passively, which only flow while some other feature (channels sheet, location notes, geo sampling) happens to pump location — turn those off and the bridge never gets a rendezvous. BridgeService now requests a one-shot fix whenever it's enabled without a cell (and piggybacks one on the presence timer so moving devices migrate cells). Also from the session: the people sheet's scroll content hugged its widest child and got centered on iPhone when the list was empty — pinned to full width, leading. And the courier-drop subscription rebuilt every ~60s on verified announces despite an unchanged tag set — now resubscribes only when the tags actually change. (Also merges origin/main: keychain test isolation #1413.) Co-Authored-By: Claude Fable 5 * Fix launch race: bridge reacts to the permission callback, retries cell-less Field session 4: the launch-time location request ran before the CoreLocation authorization callback delivered, so refreshChannels() silently no-opped (it requires .authorized) and nothing ever retried — the bridge stayed cell-less all session. Three layers now close it: - a $permissionState sink re-enters refreshRendezvous the moment authorization resolves (the fast path), - the maintenance timer arms even without a cell and retries the full rendezvous refresh (the backstop; it previously required a cell, which made it useless for exactly this failure), - flipping the bridge switch while never-asked triggers the location prompt — that's the user-initiated moment for it. Co-Authored-By: Claude Fable 5 * People sheet: one header style for every section; bridged people visible while own bridge is off GroupChatList's header now uses the shared PeopleSectionHeader (glyph + label, same size/padding as #mesh and across-the-bridge; keeps its key and header trait). Bridge section and the header count are no longer gated on this device's own toggle: bridged people arrive over passive radio from a serving neighbor, and whoever is visible in the timeline belongs in the sheet and the count. Co-Authored-By: Claude Fable 5 * People sheet: equalize the first section's gap MeshPeerList's first row kept a legacy 10pt top bump from when nothing sat above it, and the outer VStack's 6pt inter-child spacing applied between the #mesh header and the list but not inside the other sections. Both gone: sections own their rhythm (header 12/4, rows 4), spacing 0 outside. Co-Authored-By: Claude Fable 5 * People sheet: rows drop their leading glyphs — the section header carries the type Mesh rows lose the per-state transport icon (connected/relayed/nostr/ offline), bridge rows the network glyph, group rows the person.3 icon. Trailing state badges (star, lock, verified, unread, blocked, crown) stay, and the row accessibility description still announces connection state, so VoiceOver loses nothing. Co-Authored-By: Claude Fable 5 * Remove dead code left behind by the Settings|Info consolidation Periphery (--strict) flagged five leftovers on this branch: - LocationChannelsModel.setGatewayEnabled: the standalone internet-gateway toggle is gone (the bridge switch drives internet sharing), so nothing calls it; the gatewayEnabled published property stays for the header dot. - AppInfoView Strings.Location title/enable/openSettings: the old Location section's header and permission buttons no longer exist. Their orphaned Localizable.xcstrings entries go with them (the gateway-toggle keys were already pruned). - BridgePeopleList's appTheme environment value was never read. The sixth CI finding (PrekeyBundleStore.StoredBundle.noiseKey assign-only) is a Periphery flake: the property is read in loadFromDisk, the finding didn't reproduce locally or on the next CI run of unchanged code. Co-Authored-By: Claude Fable 5 * People sheet: mesh rows get their transport glyph back The mesh section is the one heterogeneous list — the leading icon encodes HOW a peer is reachable (radio / relayed / nostr-only / offline), which the header can't say. Bridge and group rows stay glyph-free. Co-Authored-By: Claude Fable 5 * Fix courier drop skipped by stale BLE reachability; periodic deposit sweep Field test: a DM sent seconds after the recipient's radio vanished still saw them as "reachable" (60s verified retention), so canDeliverPromptly held, every deposit was skipped, and the message sat spooled with no retry path. MessageRouter now sweeps its outbox every 2 minutes and publishes bridge drops for messages whose recipient no transport can promptly reach; the drop layer's message-ID dedup makes the sweep idempotent. Regression test reproduces the exact field sequence. Co-Authored-By: Claude Fable 5 * Show "carried" when a message ships as a bridge drop Field feedback: dropped DMs delivered but the sender saw nothing — the drop path never fired onMessageCarried, and the recipient's delivery ack has no radio route back until the peers next share a transport. depositDrop now reports whether a fresh drop was sealed and the router marks the message carried (📦) on both the send path and the sweep; the ack still upgrades it to delivered whenever a route exists. Co-Authored-By: Claude Fable 5 * Fix locale word order: offline tag after name in DM header; localized "ago" The private-chat header rendered the localized offline word in the availability-glyph slot, before the name — "sin conexión bob". Offline now shows the same dimmed person glyph the mesh list uses, with the word as a small trailing tag after the name and lock, so it reads correctly in every locale. Full sweep of views found one more composition bug: notice timestamps glued English "ago" onto a localized duration; now the whole phrase comes from RelativeDateTimeFormatter (same as the "fades" label). Co-Authored-By: Claude Fable 5 * DM header offline state: icon only The availability slot now reads uniformly as a glyph (radio / relayed / globe / dimmed person), matching the mesh list; the text tag is gone. VoiceOver still announces "offline" via the glyph's accessibility label. Co-Authored-By: Claude Fable 5 * Offline glyph: slashed antenna instead of dimmed person Offline is now the visual negation of connected (same antenna glyph, slashed) in both the DM header and mesh list rows; a generic person icon didn't say "unreachable". Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/LocationChannelsModel.swift | 4 - bitchat/Localizable.xcstrings | 5404 ++++++++++++++--- bitchat/Nostr/NostrIdentityBridge.swift | 7 + bitchat/Nostr/NostrProtocol.swift | 82 + bitchat/Nostr/NostrRelayManager.swift | 23 + bitchat/Protocols/NostrCarrierPacket.swift | 8 + bitchat/Protocols/Packets.swift | 26 +- bitchat/Services/BLE/BLEPeerRegistry.swift | 17 +- bitchat/Services/BLE/BLEService.swift | 137 +- bitchat/Services/Courier/CourierStore.swift | 29 + bitchat/Services/Gateway/BoundedIDSet.swift | 35 + .../Gateway/BridgeCourierService.swift | 284 + bitchat/Services/Gateway/BridgeService.swift | 689 +++ bitchat/Services/Gateway/GatewayService.swift | 31 +- bitchat/Services/MessageRouter.swift | 52 + .../ViewModels/ChatOutgoingCoordinator.swift | 8 + .../ChatViewModelBootstrapper.swift | 231 +- bitchat/Views/AppInfoView.swift | 477 +- bitchat/Views/BridgePeopleList.swift | 76 + bitchat/Views/Components/IRCToggleStyle.swift | 34 + .../Views/Components/TextMessageView.swift | 9 + bitchat/Views/ContentComposerView.swift | 40 + bitchat/Views/ContentHeaderView.swift | 35 +- bitchat/Views/ContentSheetViews.swift | 89 +- bitchat/Views/ContentView.swift | 7 +- bitchat/Views/GroupChatList.swift | 17 +- bitchat/Views/LocationChannelsSheet.swift | 123 +- bitchat/Views/MeshPeerList.swift | 24 +- bitchat/Views/NoticesView.swift | 13 +- .../ChatOutgoingCoordinatorContextTests.swift | 5 + .../EndToEnd/CourierEndToEndTests.swift | 44 + .../Protocols/BridgeWireFormatTests.swift | 149 + .../Services/BridgeCourierServiceTests.swift | 284 + .../Services/BridgeServiceTests.swift | 594 ++ bitchatTests/ViewSmokeTests.swift | 4 +- .../BitFoundation/BitchatMessage.swift | 35 +- .../BitFoundation/PeerCapabilities.swift | 4 + .../Sources/BitFoundation/PeerID.swift | 15 + .../BitchatMessageBridgedFlagTests.swift | 57 + 39 files changed, 7925 insertions(+), 1277 deletions(-) create mode 100644 bitchat/Services/Gateway/BoundedIDSet.swift create mode 100644 bitchat/Services/Gateway/BridgeCourierService.swift create mode 100644 bitchat/Services/Gateway/BridgeService.swift create mode 100644 bitchat/Views/BridgePeopleList.swift create mode 100644 bitchat/Views/Components/IRCToggleStyle.swift create mode 100644 bitchatTests/Protocols/BridgeWireFormatTests.swift create mode 100644 bitchatTests/Services/BridgeCourierServiceTests.swift create mode 100644 bitchatTests/Services/BridgeServiceTests.swift create mode 100644 localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift index 230e75a0..25cb2b9f 100644 --- a/bitchat/App/LocationChannelsModel.swift +++ b/bitchat/App/LocationChannelsModel.swift @@ -100,10 +100,6 @@ final class LocationChannelsModel: ObservableObject { network.setUserTorEnabled(enabled) } - func setGatewayEnabled(_ enabled: Bool) { - gateway.setEnabled(enabled) - } - func refreshMeshChannelsIfNeeded() { guard case .mesh = selectedChannel, permissionState == .authorized, diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 6fb76002..64067a7c 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -189,6 +189,7 @@ }, "%@ active" : { "comment" : "A label at the bottom of the people list sheet showing the number of active users.", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -1261,6 +1262,364 @@ } } }, + "app_info.features.bridge.description" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يربط جزر mesh القريبة عبر الإنترنت حتى لا ينقسم الحشد الواحد بسبب مدى الراديو" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে, যাতে রেডিও পরিসরের কারণে একই ভিড় ভাগ না হয়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindet nahe mesh-inseln über das internet, damit eine menge nicht durch die funkreichweite geteilt wird" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "links nearby mesh islands through the internet so one crowd isn't split by radio range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "une islas mesh cercanas a través de internet para que una multitud no quede dividida por el alcance de radio" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "iniuugnay ang mga kalapit na mesh island sa pamamagitan ng internet para hindi mahati ng saklaw ng radyo ang iisang grupo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relie les îlots mesh voisins via internet pour qu'une même foule ne soit pas coupée par la portée radio" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מקשר איי mesh קרובים דרך האינטרנט כדי שקהל אחד לא יתפצל בגלל טווח הרדיו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है ताकि एक ही भीड़ रेडियो दायरे से बँट न जाए" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan pulau-pulau mesh terdekat lewat internet agar satu kerumunan tidak terbelah oleh jangkauan radio" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "collega le isole mesh vicine tramite internet così una folla non viene divisa dalla portata radio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのmeshの島々をインターネットでつなぎ、電波範囲で人の輪が分断されないようにします" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처의 mesh 섬들을 인터넷으로 연결해 한 무리가 전파 범위 때문에 갈라지지 않게 합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan pulau-pulau mesh berdekatan melalui internet supaya satu kumpulan tidak terpisah oleh jangkauan radio" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ ताकि एउटै भीड रेडियो पहुँचले नबाँडियोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindt nabije mesh-eilanden via internet zodat één groep niet door radiobereik wordt gesplitst" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączy pobliskie wyspy mesh przez internet, aby jeden tłum nie był dzielony przez zasięg radiowy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "liga ilhas mesh próximas através da internet para que uma multidão não fique dividida pelo alcance de rádio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "liga ilhas mesh próximas pela internet para que uma multidão não fique dividida pelo alcance de rádio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "связывает соседние mesh-острова через интернет, чтобы одна толпа не делилась радиусом радиосвязи" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "länkar närliggande mesh-öar via internet så att en folkmassa inte delas av radioräckvidden" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது, ஒரே கூட்டம் ரேடியோ வரம்பால் பிரியாமல் இருக்க" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต เพื่อให้ฝูงชนเดียวกันไม่ถูกแบ่งด้วยระยะวิทยุ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki mesh adalarını internet üzerinden birbirine bağlar, böylece bir kalabalık telsiz menziliyle bölünmez" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "з'єднує сусідні mesh-острови через інтернет, щоб один натовп не ділився радіусом радіозв'язку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے تاکہ ایک ہجوم ریڈیو رینج سے تقسیم نہ ہو" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "liên kết các đảo mesh gần nhau qua internet để một đám đông không bị chia cắt bởi phạm vi sóng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过互联网连接附近的 mesh 孤岛,让同一群人不被无线电范围隔开" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過網際網路連接附近的 mesh 孤島,讓同一群人不被無線電範圍隔開" + } + } + } + }, + "app_info.features.bridge.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جسور mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজিং" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bridging" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridging" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puentes mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridging" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontage mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גישור mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिजिंग" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "bridging mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジング" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리징" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh ब्रिजिङ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bridging" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mostkowanie mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontes mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "pontes mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мосты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-bryggning" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh köprüleme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мости" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 橋接" + } + } + } + }, "app_info.features.encryption.description" : { "extractionState" : "manual", "localizations" : { @@ -5021,6 +5380,185 @@ } } }, + "app_info.legend.bridged" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وصلت الرسالة عبر جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তাটি মেশ ব্রিজ পেরিয়ে এসেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachricht kam über eine mesh-brücke an" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message arrived across a mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "el mensaje llegó a través de un puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "dumating ang mensahe sa pamamagitan ng mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "message arrivé via un pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ההודעה הגיעה דרך גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश मेश ब्रिज से होकर आया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan tiba lewat jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "messaggio arrivato attraverso un ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ経由で届いたメッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지를 거쳐 도착한 메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan tiba melalui jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देश mesh पुल हुँदै आइपुग्यो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bericht kwam via een mesh-brug binnen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wiadomość dotarła przez most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem chegou através de uma ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem chegou por uma ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сообщение пришло через mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "meddelandet kom via en mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி mesh பாலம் வழியாக வந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อความมาถึงผ่านบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj bir mesh köprüsü üzerinden geldi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення надійшло через mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام mesh پل کے ذریعے پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tin nhắn đến qua cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息经 mesh 桥接送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "訊息經 mesh 橋接送達" + } + } + } + }, "app_info.legend.encrypted" : { "comment" : "Legend entry for the lock glyph", "extractionState" : "manual", @@ -7181,186 +7719,6 @@ } } }, - "app_info.location.enable" : { - "comment" : "Button in app info requesting location permission", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "تفعيل الوصول إلى الموقع" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবস্থান অ্যাক্সেস চালু করুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "standortzugriff aktivieren" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "enable location access" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "activar acceso a la ubicación" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "i-enable ang access sa lokasyon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "activer l'accès à la localisation" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "אפשר גישה למיקום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान एक्सेस चालू करें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "aktifkan akses lokasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "attiva accesso alla posizione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置情報へのアクセスを有効にする" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치 접근 허용" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "aktifkan akses lokasi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान पहुँच सक्षम गर्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "locatietoegang inschakelen" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "włącz dostęp do lokalizacji" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "ativar acesso à localização" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "ativar acesso à localização" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "включить доступ к геолокации" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "aktivera platsåtkomst" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருப்பிட அணுகலை இயக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปิดการเข้าถึงตำแหน่งที่ตั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konum erişimini etkinleştir" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "увімкнути доступ до місцезнаходження" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقام تک رسائی فعال کریں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "bật quyền truy cập vị trí" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用位置权限" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "啟用位置權限" - } - } - } - }, "app_info.location.notes.description" : { "comment" : "Description of the location notes toggle in app info", "extractionState" : "manual", @@ -7721,366 +8079,6 @@ } } }, - "app_info.location.open_settings" : { - "comment" : "Button in app info when location permission was denied", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "افتح إعدادات النظام للسماح بالموقع" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবস্থান অনুমতি দিতে সিস্টেম সেটিংস খুলুন" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "systemeinstellungen öffnen, um standort zu erlauben" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "open system settings to allow location" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "abre los ajustes del sistema para permitir la ubicación" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "buksan ang system settings para payagan ang lokasyon" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "ouvre les réglages système pour autoriser la localisation" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "פתח את הגדרות המערכת כדי לאפשר מיקום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान की अनुमति देने के लिए सिस्टम सेटिंग्स खोलें" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "buka pengaturan sistem untuk mengizinkan lokasi" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "apri le impostazioni di sistema per consentire la posizione" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "システム設定を開いて位置情報を許可" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "시스템 설정을 열어 위치 허용" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "buka tetapan sistem untuk benarkan lokasi" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान अनुमति दिन प्रणाली सेटिङ खोल्नुहोस्" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "open systeeminstellingen om locatie toe te staan" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "otwórz ustawienia systemowe, aby zezwolić na lokalizację" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "abrir definições do sistema para permitir a localização" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "abrir os ajustes do sistema para permitir a localização" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "откройте настройки системы, чтобы разрешить геолокацию" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "öppna systeminställningar för att tillåta plats" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருப்பிடத்தை அனுமதிக்க கணினி அமைப்புகளைத் திறக்கவும்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "เปิดการตั้งค่าระบบเพื่ออนุญาตตำแหน่งที่ตั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "konuma izin vermek için sistem ayarlarını aç" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "відкрийте системні налаштування, щоб дозволити місцезнаходження" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقام کی اجازت دینے کے لیے سسٹم سیٹنگز کھولیں" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "mở cài đặt hệ thống để cho phép vị trí" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开系统设置以允许位置访问" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "打開系統設定以允許位置存取" - } - } - } - }, - "app_info.location.title" : { - "comment" : "Section header for location settings in the app info sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "الموقع" - } - }, - "bn" : { - "stringUnit" : { - "state" : "translated", - "value" : "অবস্থান" - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "STANDORT" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOCATION" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "UBICACIÓN" - } - }, - "fil" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOKASYON" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOCALISATION" - } - }, - "he" : { - "stringUnit" : { - "state" : "translated", - "value" : "מיקום" - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान" - } - }, - "id" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOKASI" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "POSIZIONE" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置情報" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "위치" - } - }, - "ms" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOKASI" - } - }, - "ne" : { - "stringUnit" : { - "state" : "translated", - "value" : "स्थान" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOCATIE" - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOKALIZACJA" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOCALIZAÇÃO" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "translated", - "value" : "LOCALIZAÇÃO" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "ГЕОЛОКАЦИЯ" - } - }, - "sv" : { - "stringUnit" : { - "state" : "translated", - "value" : "PLATS" - } - }, - "ta" : { - "stringUnit" : { - "state" : "translated", - "value" : "இருப்பிடம்" - } - }, - "th" : { - "stringUnit" : { - "state" : "translated", - "value" : "ตำแหน่งที่ตั้ง" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "KONUM" - } - }, - "uk" : { - "stringUnit" : { - "state" : "translated", - "value" : "МІСЦЕЗНАХОДЖЕННЯ" - } - }, - "ur" : { - "stringUnit" : { - "state" : "translated", - "value" : "مقام" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "VỊ TRÍ" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "位置" - } - } - } - }, "app_info.network.title" : { "comment" : "Section header for network diagnostics in the app info sheet", "extractionState" : "manual", @@ -10054,6 +10052,2333 @@ } } }, + "app_info.settings.bridge.cell" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خلية اللقاء: %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মিলনস্থল সেল: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous-zelle: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous cell: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "celda de encuentro: %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "rendezvous cell: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "cellule de rendez-vous : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תא מפגש: %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिलन सेल: %@" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sel titik temu: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cella di incontro: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ランデブーセル: %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "랑데부 셀: %@" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "sel titik temu: %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "भेट हुने सेल: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ontmoetingscel: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "komórka spotkania: %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "célula de encontro: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "célula de encontro: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ячейка встречи: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mötescell: %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "சந்திப்பு செல்: %@" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เซลล์นัดพบ: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buluşma hücresi: %@" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "комірка зустрічі: %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاقات سیل: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ô hẹn gặp: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会合单元:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "會合單元:%@" + } + } + } + }, + "app_info.settings.bridge.no_cell" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا توجد خلية لقاء بعد — يلزم صلاحية الموقع أو قرين جسر قريب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখনও কোনো মিলনস্থল সেল নেই — লোকেশন অ্যাক্সেস বা কাছাকাছি একটি ব্রিজ পিয়ার দরকার" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "noch keine rendezvous-zelle — braucht standortzugriff oder einen brücken-peer in der nähe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no rendezvous cell yet — needs location access or a nearby bridge peer" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "aún no hay celda de encuentro — necesita acceso a la ubicación o un peer puente cercano" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "wala pang rendezvous cell — kailangan ng access sa lokasyon o kalapit na bridge peer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pas encore de cellule de rendez-vous — nécessite l'accès localisation ou un pair pont à proximité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עדיין אין תא מפגש — נדרשת גישת מיקום או עמית גשר קרוב" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "अभी कोई मिलन सेल नहीं — लोकेशन एक्सेस या आसपास का कोई ब्रिज पीयर चाहिए" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada sel titik temu — perlu akses lokasi atau peer jembatan terdekat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ancora nessuna cella di incontro — serve l'accesso alla posizione o un peer ponte vicino" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ランデブーセルはまだありません — 位置アクセスか近くのブリッジピアが必要です" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 랑데부 셀이 없습니다 — 위치 접근 권한이나 근처의 브리지 피어가 필요합니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "belum ada sel titik temu — perlu capaian lokasi atau peer jambatan berdekatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "अहिलेसम्म भेट हुने सेल छैन — स्थान पहुँच वा नजिकैको bridge peer चाहिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nog geen ontmoetingscel — vereist toegang tot locatie of een brug-peer in de buurt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "nie ma jeszcze komórki spotkania — potrzebny dostęp do lokalizacji lub pobliski peer mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte próximo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ainda sem célula de encontro — precisa de acesso à localização ou de um par ponte por perto" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ячейки встречи пока нет — нужен доступ к локации или ближайший пир-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ingen mötescell ännu — kräver platsåtkomst eller en brygg-peer i närheten" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்னும் சந்திப்பு செல் இல்லை — இட அணுகல் அல்லது அருகிலுள்ள பாலம் peer தேவை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ยังไม่มีเซลล์นัดพบ — ต้องมีสิทธิ์เข้าถึงตำแหน่งหรือเพียร์บริดจ์ที่อยู่ใกล้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "henüz buluşma hücresi yok — konum erişimi veya yakında bir köprü eşi gerekiyor" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "комірки зустрічі поки немає — потрібен доступ до локації або ближній пір-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ابھی کوئی ملاقات سیل نہیں — لوکیشن رسائی یا قریبی پل ہم منصب درکار ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chưa có ô hẹn gặp — cần quyền truy cập vị trí hoặc một nút cầu nối gần đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚无会合单元 — 需要位置访问或附近有桥接同伴" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚無會合單元 — 需要位置存取權或附近有橋接同伴" + } + } + } + }, + "app_info.settings.bridge.subtitle" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يصل جزر mesh القريبة عبر الإنترنت: ما تقوله في قناة mesh يصل أيضًا إلى أشخاص في منطقتك خارج مدى الراديو، وتظهر رسائلهم هنا معلَّمة برمز الشبكة. ما دام لديك إنترنت، يحمل جهازك أيضًا حركة الجسر وقنوات الموقع للهواتف من حولك التي لا تملك اتصالًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কাছাকাছি মেশ দ্বীপগুলোকে ইন্টারনেটের মাধ্যমে যুক্ত করে: মেশ চ্যানেলে আপনি যা বলেন তা আপনার এলাকার রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়, আর তাদের বার্তা নেটওয়ার্ক চিহ্নসহ এখানে দেখা যায়। আপনার ইন্টারনেট থাকাকালীন, আপনার ডিভাইস আশপাশের ইন্টারনেটবিহীন ফোনের জন্য ব্রিজ ও লোকেশন-চ্যানেলের ট্র্যাফিকও বহন করে।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindet nahe mesh-inseln über das internet: was du im mesh-kanal sagst, erreicht auch personen in deiner gegend außerhalb der funkreichweite, und ihre nachrichten erscheinen hier mit dem netzwerk-symbol markiert. solange du internet hast, trägt dein gerät auch brücken- und standortkanal-verkehr für handys um dich herum, die keins haben." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "une islas mesh cercanas a través de internet: lo que dices en el canal mesh también llega a personas de tu zona fuera del alcance de radio, y sus mensajes aparecen aquí marcados con el símbolo de red. mientras tengas internet, tu dispositivo también lleva el tráfico del puente y de los canales de ubicación para teléfonos a tu alrededor que no tienen conexión." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinagdurugtong ang mga kalapit na mesh island sa pamamagitan ng internet: ang sinasabi mo sa mesh channel ay umaabot din sa mga tao sa iyong lugar na lampas sa saklaw ng radyo, at lumalabas dito ang kanilang mga mensahe na may markang simbolo ng network. habang may internet ka, dinadala rin ng device mo ang trapiko ng bridge at ng mga channel ng lokasyon para sa mga teleponong nasa paligid mo na walang koneksyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relie les îlots mesh voisins via internet : ce que tu dis dans le canal mesh atteint aussi les personnes de ta zone hors de portée radio, et leurs messages apparaissent ici marqués du symbole réseau. tant que tu as internet, ton appareil achemine aussi le trafic du pont et des canaux localisation pour les téléphones autour de toi qui n'en ont pas." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחבר איי mesh קרובים דרך האינטרנט: מה שאתה אומר בערוץ ה-mesh מגיע גם לאנשים באזורך מעבר לטווח הרדיו, וההודעות שלהם מופיעות כאן מסומנות בסמל הרשת. כל עוד יש לך אינטרנט, המכשיר שלך גם מעביר תעבורת גשר וערוצי מיקום עבור טלפונים סביבך שאין להם חיבור." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आसपास के मेश द्वीपों को इंटरनेट से जोड़ता है: मेश चैनल में आप जो कहते हैं वह आपके क्षेत्र में रेडियो दायरे से बाहर के लोगों तक भी पहुँचता है, और उनके संदेश यहाँ नेटवर्क चिह्न के साथ दिखते हैं। जब तक आपके पास इंटरनेट है, आपका डिवाइस आसपास के बिना इंटरनेट वाले फोनों के लिए ब्रिज और लोकेशन-चैनल का ट्रैफ़िक भी पहुँचाता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyatukan pulau-pulau mesh terdekat lewat internet: yang kamu katakan di kanal mesh juga sampai ke orang-orang di areamu di luar jangkauan radio, dan pesan mereka muncul di sini ditandai dengan simbol jaringan. selama kamu punya internet, perangkatmu juga membawa lalu lintas jembatan dan kanal lokasi untuk ponsel di sekitarmu yang tidak punya koneksi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "unisce le isole mesh vicine tramite internet: ciò che dici nel canale mesh raggiunge anche persone della tua zona oltre la portata radio, e i loro messaggi appaiono qui contrassegnati con il simbolo di rete. finché hai internet, il tuo dispositivo trasporta anche il traffico del ponte e dei canali posizione per i telefoni intorno a te che non ne hanno." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのmeshの島々をインターネットでつなぎます: meshチャンネルでの発言はエリア内の電波範囲外の人にも届き、相手のメッセージはネットワーク記号付きでここに表示されます。インターネットがある間は、あなたの端末が周囲の接続を持たない端末のためにブリッジとロケーションチャンネルの通信も運びます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처의 mesh 섬들을 인터넷으로 연결합니다: mesh 채널에서 말한 내용이 이 지역의 전파 범위 밖 사람들에게도 전달되고, 그들의 메시지는 네트워크 기호가 붙어 여기에 표시됩니다. 인터넷이 있는 동안에는 내 기기가 주변의 연결이 없는 휴대폰을 위해 브리지와 위치 채널 트래픽도 전달합니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyatukan pulau-pulau mesh berdekatan melalui internet: apa yang kamu katakan di kanal mesh juga sampai kepada orang di kawasanmu di luar jangkauan radio, dan pesan mereka muncul di sini ditanda dengan simbol rangkaian. selagi kamu ada internet, perantimu juga membawa trafik jambatan dan kanal lokasi untuk telefon di sekelilingmu yang tiada sambungan." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिकका mesh टापुहरूलाई इन्टरनेटमार्फत जोड्छ: mesh च्यानलमा तिमीले भनेको कुरा तिम्रो क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पनि पुग्छ, र उनीहरूका सन्देश नेटवर्क चिन्हसहित यहाँ देखिन्छन्। तिमीसँग इन्टरनेट भएसम्म, तिम्रो यन्त्रले वरपरका इन्टरनेट नभएका फोनहरूका लागि पुल र स्थान-च्यानलको ट्राफिक पनि बोक्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbindt nabije mesh-eilanden via internet: wat je in het mesh-kanaal zegt bereikt ook mensen in jouw omgeving buiten radiobereik, en hun berichten verschijnen hier gemarkeerd met het netwerksymbool. zolang je internet hebt, draagt je apparaat ook brug- en locatiekanaalverkeer voor telefoons om je heen die geen verbinding hebben." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączy pobliskie wyspy mesh przez internet: to, co mówisz na kanale mesh, dociera też do osób w twojej okolicy poza zasięgiem radiowym, a ich wiadomości pojawiają się tutaj oznaczone symbolem sieci. dopóki masz internet, twoje urządzenie przenosi też ruch mostu i kanałów lokalizacji dla telefonów wokół ciebie, które go nie mają." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "junta ilhas mesh próximas através da internet: o que dizes no canal mesh também chega a pessoas na tua zona fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto tens internet, o teu dispositivo também transporta o tráfego da ponte e dos canais de localização para telemóveis à tua volta que não têm ligação." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "junta ilhas mesh próximas pela internet: o que você diz no canal mesh também chega a pessoas na sua área fora do alcance de rádio, e as mensagens delas aparecem aqui marcadas com o símbolo de rede. enquanto você tem internet, seu dispositivo também leva o tráfego da ponte e dos canais de localização para celulares ao seu redor que não têm conexão." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "объединяет соседние mesh-острова через интернет: то, что ты говоришь в mesh-канале, доходит и до людей в твоём районе за пределами радиуса радиосвязи, а их сообщения появляются здесь с пометкой символом сети. пока у тебя есть интернет, твоё устройство также переносит трафик моста и каналов локации для телефонов вокруг, у которых его нет." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kopplar ihop närliggande mesh-öar via internet: det du säger i mesh-kanalen når även personer i ditt område utom radioräckvidd, och deras meddelanden visas här markerade med nätverkssymbolen. så länge du har internet bär din enhet också brygg- och platskanaltrafik åt telefoner omkring dig som saknar anslutning." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகிலுள்ள mesh தீவுகளை இணையம் வழியாக இணைக்கிறது: mesh சேனலில் நீங்கள் சொல்வது உங்கள் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களையும் சென்றடையும், அவர்களின் செய்திகள் நெட்வொர்க் சின்னத்துடன் இங்கே தோன்றும். உங்களிடம் இணையம் இருக்கும் வரை, சுற்றியுள்ள இணைப்பு இல்லாத போன்களுக்காக உங்கள் சாதனம் பாலம் மற்றும் இட சேனல்களின் போக்குவரத்தையும் சுமக்கிறது." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เชื่อมเกาะ mesh ที่อยู่ใกล้กันผ่านอินเทอร์เน็ต: สิ่งที่คุณพูดในช่อง mesh ยังไปถึงผู้คนในพื้นที่ของคุณที่อยู่นอกระยะวิทยุ และข้อความของพวกเขาจะปรากฏที่นี่พร้อมสัญลักษณ์เครือข่ายกำกับ ตราบใดที่คุณมีอินเทอร์เน็ต อุปกรณ์ของคุณยังส่งต่อทราฟฟิกของบริดจ์และช่องตามตำแหน่งให้โทรศัพท์รอบตัวที่ไม่มีอินเทอร์เน็ตด้วย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "yakındaki mesh adalarını internet üzerinden birleştirir: mesh kanalında söylediklerin bölgendeki telsiz menzili dışındaki kişilere de ulaşır ve onların mesajları burada ağ simgesiyle işaretli olarak görünür. internetin olduğu sürece cihazın, çevrendeki bağlantısı olmayan telefonlar için köprü ve konum kanalı trafiğini de taşır." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "об'єднує сусідні mesh-острови через інтернет: те, що ти кажеш у mesh-каналі, доходить і до людей у твоєму районі поза радіусом радіозв'язку, а їхні повідомлення з'являються тут із позначкою символом мережі. поки в тебе є інтернет, твій пристрій також переносить трафік мосту й каналів локації для телефонів навколо, які його не мають." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "قریبی mesh جزیروں کو انٹرنیٹ کے ذریعے جوڑتا ہے: mesh چینل میں آپ جو کہتے ہیں وہ آپ کے علاقے میں ریڈیو رینج سے باہر لوگوں تک بھی پہنچتا ہے، اور ان کے پیغامات یہاں نیٹ ورک علامت کے ساتھ نظر آتے ہیں۔ جب تک آپ کے پاس انٹرنیٹ ہے، آپ کا آلہ ارد گرد کے بغیر انٹرنیٹ والے فونز کے لیے پل اور لوکیشن چینلز کی ٹریفک بھی پہنچاتا ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nối các đảo mesh gần nhau qua internet: những gì bạn nói trong kênh mesh cũng đến được mọi người trong khu vực ngoài phạm vi sóng, và tin nhắn của họ hiện ở đây kèm ký hiệu mạng. khi bạn có internet, thiết bị của bạn cũng chuyển lưu lượng cầu nối và kênh vị trí cho những điện thoại xung quanh không có kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通过互联网连接附近的 mesh 孤岛:你在 mesh 频道说的话也会送达本地区无线电范围之外的人,他们的消息会带网络符号显示在这里。当你有互联网时,你的设备还会为周围没有网络的手机传送桥接和位置频道流量。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "透過網際網路連接附近的 mesh 孤島:你在 mesh 頻道說的話也會送達本地區無線電範圍之外的人,他們的訊息會帶網路符號顯示在這裡。當你有網際網路時,你的裝置還會為周圍沒有網路的手機傳送橋接和位置頻道流量。" + } + } + } + }, + "app_info.settings.bridge.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिज" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh पुल" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh köprüsü" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 橋接" + } + } + } + }, + "app_info.settings.connectivity.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاتصال" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সংযোগ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONNEKTIVITÄT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDAD" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONEKTIBIDAD" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITÉ" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קישוריות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कनेक्टिविटी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "KONEKTIVITAS" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNETTIVITÀ" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "KESAMBUNGAN" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जडान" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONNECTIVITEIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ŁĄCZNOŚĆ" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDADE" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "CONECTIVIDADE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "СВЯЗЬ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ANSLUTNING" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இணைப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การเชื่อมต่อ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAĞLANTI" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЗВ'ЯЗОК" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کنیکٹیویٹی" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KẾT NỐI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線" + } + } + } + }, + "app_info.settings.danger.panic_button" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح الذعر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "প্যানিক ওয়াইপ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "panik-löschung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "panic wipe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrado de pánico" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "panic wipe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacement panique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחיקת בהלה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पैनिक वाइप" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus panik" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellazione panico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "パニック消去" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패닉 삭제" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus panik" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "घबराहट मेटाइ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "paniekwissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "awaryjne wymazanie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza de pânico" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpeza de pânico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "экстренное стирание" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "panikradering" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பதற்ற அழிப்பு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลฉุกเฉิน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "panik silme" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "екстрене стирання" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پینک وائپ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa khẩn cấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "紧急抹除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "緊急抹除" + } + } + } + }, + "app_info.settings.danger.panic_confirm_action" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح كل شيء" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব মুছে ফেলুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alles löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe everything" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrar todo" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "burahin lahat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tout effacer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק הכול" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सब कुछ मिटाएँ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semuanya" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancella tutto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて消去" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모두 지우기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semuanya" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै मेट" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alles wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymaż wszystko" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar tudo" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apagar tudo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стереть всё" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "radera allt" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அனைத்தையும் அழி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างทั้งหมด" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "her şeyi sil" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стерти все" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "سب کچھ مٹا دیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa tất cả" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部" + } + } + } + }, + "app_info.settings.danger.panic_confirm_title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح كل البيانات؟" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সব ডেটা মুছবেন?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle daten löschen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "wipe all data?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿borrar todos los datos?" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "burahin ang lahat ng data?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacer toutes les données ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "למחוק את כל הנתונים?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सारा डेटा मिटाएँ?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semua data?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancellare tutti i dati?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "全データを消去しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 데이터를 지울까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus semua data?" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै डाटा मेट्ने हो?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "alle data wissen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wymazać wszystkie dane?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar todos os dados?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apagar todos os dados?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "стереть все данные?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "radera all data?" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா தரவையும் அழிக்கவா?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้างข้อมูลทั้งหมด?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "tüm veriler silinsin mi?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стерти всі дані?" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تمام ڈیٹا مٹا دیں؟" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa toàn bộ dữ liệu?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部数据?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除全部資料?" + } + } + } + }, + "app_info.settings.danger.panic_note" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يمسح كل الرسائل والمفاتيح والهوية. النقر ثلاث مرات على شعار bitchat/ يفعل الشيء نفسه فورًا." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "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." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borra todos los mensajes, claves e identidad. tocar tres veces el logotipo de bitchat/ hace lo mismo, al instante." + } + }, + "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." + } + }, + "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." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מוחק את כל ההודעות, המפתחות והזהות. הקשה משולשת על הלוגו של bitchat/ עושה את אותו הדבר, מייד." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सभी संदेश, कुंजियाँ और पहचान मिटा देता है। bitchat/ लोगो पर तीन बार टैप करने से भी तुरंत यही होता है।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghapus semua pesan, kunci, dan identitas. mengetuk logo bitchat/ tiga kali melakukan hal yang sama, seketika." + } + }, + "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." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべてのメッセージ、鍵、アイデンティティを消去します。bitchat/ ロゴを3回タップしても同じことが即座に行われます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 메시지, 키, 신원을 지웁니다. bitchat/ 로고를 세 번 탭해도 즉시 같은 동작이 실행됩니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghapus semua pesan, kunci, dan identiti. mengetuk logo bitchat/ tiga kali melakukan perkara yang sama, serta-merta." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सबै सन्देश, कुञ्जी र पहिचान मेटाउँछ। bitchat/ लोगोमा तीन पटक ट्याप गर्दा पनि तुरुन्तै त्यही हुन्छ।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wist alle berichten, sleutels en identiteit. drie keer tikken op het bitchat/-logo doet hetzelfde, direct." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "usuwa wszystkie wiadomości, klucze i tożsamość. trzykrotne stuknięcie logo bitchat/ robi to samo, natychmiast." + } + }, + "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." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "apaga todas as mensagens, chaves e identidade. tocar três vezes no logo bitchat/ faz o mesmo, instantaneamente." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "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." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "எல்லா செய்திகள், சாவிகள், அடையாளத்தையும் அழிக்கிறது. bitchat/ லோகோவை மூன்று முறைத் தட்டினாலும் உடனடியாக அதுவே நடக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "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." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "стирає всі повідомлення, ключі та особистість. потрійний дотик до логотипа bitchat/ робить те саме, миттєво." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "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." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除所有消息、密钥和身份。三击 bitchat/ 标志也会立即执行相同操作。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "抹除所有訊息、金鑰和身分。三擊 bitchat/ 標誌也會立即執行相同操作。" + } + } + } + }, + "app_info.settings.danger.title" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منطقة الخطر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বিপদ অঞ্চল" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GEFAHRENZONE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DANGER ZONE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PELIGRO" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "DANGER ZONE" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONE DE DANGER" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזור סכנה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "खतरे का क्षेत्र" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA BERBAHAYA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA PERICOLOSA" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "危険ゾーン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위험 구역" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZON BAHAYA" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "खतरा क्षेत्र" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "GEVARENZONE" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "STREFA ZAGROŻENIA" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PERIGO" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ZONA DE PERIGO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОПАСНАЯ ЗОНА" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "FARLIG ZON" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஆபத்து மண்டலம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โซนอันตราย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TEHLİKELİ BÖLGE" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "НЕБЕЗПЕЧНА ЗОНА" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطرے کا علاقہ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "VÙNG NGUY HIỂM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "危险区域" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "危險區域" + } + } + } + }, + "app_info.tab.info" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "معلومات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "তথ্য" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "impormasyon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "infos" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जानकारी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "情報" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정보" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "जानकारी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "informacje" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "informações" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "инфо" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "info" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "தகவல்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อมูล" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bilgi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "інфо" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "معلومات" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thông tin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "信息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資訊" + } + } + } + }, + "app_info.tab.picker_label" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عرض" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ভিউ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ansicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "view" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "view" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "vue" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תצוגה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "दृश्य" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tampilan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "表示" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "paparan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "दृश्य" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "weergave" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "widok" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "vista" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "exibição" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "вид" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "vy" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "காட்சி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มุมมอง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "görünüm" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "вигляд" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "منظر" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "chế độ xem" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "视图" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "檢視" + } + } + } + }, + "app_info.tab.settings" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সেটিংস" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "settings" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "réglages" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सेटिंग्स" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pengaturan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tetapan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सेटिङ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ustawienia" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "definições" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ajustes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "настройки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inställningar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அமைப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ตั้งค่า" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ayarlar" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "налаштування" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "سیٹنگز" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + } + } + }, "app_info.tagline" : { "extractionState" : "manual", "localizations" : { @@ -10770,6 +13095,366 @@ } } }, + "bridge_people.accessibility.row_hint" : { + "comment" : "Accessibility hint for a person listed in the bridge section of the people sheet", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في منطقتك، متصل عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আপনার এলাকায়, সেতুর মাধ্যমে সংযুক্ত" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In deiner Gegend, verbunden über die Brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In your area, connected through the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "En tu zona, conectado a través del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nasa inyong lugar, konektado sa pamamagitan ng tulay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans votre zone, connecté via le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "באזור שלך, מחובר דרך הגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आपके क्षेत्र में, पुल के माध्यम से जुड़ा हुआ" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di area Anda, terhubung melalui jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nella tua zona, connesso tramite il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "あなたのエリア内、ブリッジ経由で接続" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "당신의 지역에서 브리지를 통해 연결됨" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di kawasan anda, disambungkan melalui jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तपाईंको क्षेत्रमा, पुल मार्फत जोडिएको" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "In jouw omgeving, verbonden via de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "W Twojej okolicy, połączony przez most" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na sua área, ligado através da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na sua área, conectado pela ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В вашем районе, на связи через мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "I ditt område, ansluten via bron" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "உங்கள் பகுதியில், பாலம் வழியாக இணைக்கப்பட்டுள்ளது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ในพื้นที่ของคุณ เชื่อมต่อผ่านสะพาน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bölgenizde, köprü üzerinden bağlı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вашому районі, на зв'язку через міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آپ کے علاقے میں، پل کے ذریعے منسلک" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trong khu vực của bạn, kết nối qua cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在您的区域内,通过桥接连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在您的區域內,透過橋接連線" + } + } + } + }, + "bridge_people.section_title" : { + "comment" : "Section header in the people sheet for participants reachable via the mesh bridge", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "সেতুর ওপারে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "über die brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "across the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "al otro lado del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "sa kabila ng tulay" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "de l'autre côté du pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מעבר לגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल के पार" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "di seberang jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "oltre il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジの向こう側" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 건너편" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "di seberang jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल पारि" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "aan de overkant van de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "po drugiej stronie mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "do outro lado da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "do outro lado da ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "по ту сторону моста" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "på andra sidan bron" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலத்தின் மறுபுறம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อีกฝั่งของสะพาน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "köprünün öte yanında" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "по той бік мосту" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل کے پار" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bên kia cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "桥的另一端" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橋的另一端" + } + } + } + }, "Choose an image" : { "comment" : "A label displayed above a button that allows the user to choose an image to send.", "extractionState" : "manual", @@ -13459,6 +16144,364 @@ } } }, + "content.accessibility.bridged_count" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld أشخاص آخرون عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্রিজের ওপারে আরও %lld জন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld weitere personen über die brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld more people across the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personas más al otro lado del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld pang tao sa kabila ng bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personnes de plus via le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עוד %lld אנשים מעבר לגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्रिज के पार %lld और लोग" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld orang lagi di seberang jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "altre %lld persone oltre il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジの先にさらに%lld人" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 너머 %lld명 더" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld lagi orang di seberang jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुलपारि थप %lld जना" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld extra mensen via de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld więcej osób po drugiej stronie mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mais %lld pessoas do outro lado da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mais %lld pessoas do outro lado da ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ещё %lld человек по ту сторону моста" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld personer till via bryggan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலத்தின் அப்பால் மேலும் %lld பேர்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อีก %lld คนที่อีกฝั่งของบริดจ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "köprünün ötesinde %lld kişi daha" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "ще %lld людей по той бік мосту" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل کے پار %lld مزید لوگ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "thêm %lld người bên kia cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "桥另一侧还有 %lld 人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "橋另一側還有 %lld 人" + } + } + } + }, + "content.accessibility.bridged_message" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وصلت عبر جسر mesh" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মেশ ব্রিজ পেরিয়ে এসেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über eine mesh-brücke angekommen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrived across a mesh bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Llegó a través de un puente mesh" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dumating sa pamamagitan ng mesh bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrivé via un pont mesh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגיעה דרך גשר mesh" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "मेश ब्रिज से होकर आया" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiba lewat jembatan mesh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arrivato attraverso un ponte mesh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "meshブリッジ経由で届きました" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh 브리지를 거쳐 도착함" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiba melalui jambatan mesh" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh पुल हुँदै आइपुग्यो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via een mesh-brug binnengekomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dotarła przez most mesh" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chegou através de uma ponte mesh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chegou por uma ponte mesh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пришло через mesh-мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kom via en mesh-brygga" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh பாலம் வழியாக வந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มาถึงผ่านบริดจ์ mesh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir mesh köprüsü üzerinden geldi" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Надійшло через mesh-міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesh پل کے ذریعے پہنچا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đến qua cầu nối mesh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "经 mesh 桥接送达" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "經 mesh 橋接送達" + } + } + } + }, "content.accessibility.choose_photo" : { "comment" : "Accessibility label for the macOS photo picker button", "extractionState" : "manual", @@ -14357,182 +17400,181 @@ } } }, - "content.accessibility.gateway_hint" : { - "comment" : "Accessibility hint for the internet gateway indicator explaining a tap opens the channel sheet", + "content.accessibility.gateway_settings_hint" : { "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "needs_review", - "value" : "يفتح إعدادات القنوات لتشغيل البوابة أو إيقافها" + "state" : "translated", + "value" : "يفتح الإعدادات لتشغيل البوابة أو إيقافها" } }, "bn" : { "stringUnit" : { - "state" : "needs_review", - "value" : "গেটওয়ে চালু বা বন্ধ করতে চ্যানেল সেটিংস খোলে" + "state" : "translated", + "value" : "গেটওয়ে চালু বা বন্ধ করতে সেটিংস খোলে" } }, "de" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Öffnet die kanaleinstellungen, um das gateway ein- oder auszuschalten" + "state" : "translated", + "value" : "Öffnet die einstellungen, um das gateway ein- oder auszuschalten" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Opens channel settings to turn the gateway on or off" + "value" : "Opens settings to turn the gateway on or off" } }, "es" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Abre los ajustes de canales para activar o desactivar la puerta de enlace" + "state" : "translated", + "value" : "Abre los ajustes para activar o desactivar la puerta de enlace" } }, "fil" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Binubuksan ang mga setting ng channel para i-on o i-off ang gateway" + "state" : "translated", + "value" : "Binubuksan ang mga setting para i-on o i-off ang gateway" } }, "fr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Ouvre les réglages des canaux pour activer ou désactiver la passerelle" + "state" : "translated", + "value" : "Ouvre les réglages pour activer ou désactiver la passerelle" } }, "he" : { "stringUnit" : { - "state" : "needs_review", - "value" : "פותח את הגדרות הערוצים כדי להפעיל או לכבות את השער" + "state" : "translated", + "value" : "פותח את ההגדרות כדי להפעיל או לכבות את השער" } }, "hi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "गेटवे चालू या बंद करने के लिए चैनल सेटिंग्स खोलता है" + "state" : "translated", + "value" : "गेटवे चालू या बंद करने के लिए सेटिंग्स खोलता है" } }, "id" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Membuka pengaturan kanal untuk menyalakan atau mematikan gateway" + "state" : "translated", + "value" : "Membuka pengaturan untuk menyalakan atau mematikan gateway" } }, "it" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Apre le impostazioni dei canali per attivare o disattivare il gateway" + "state" : "translated", + "value" : "Apre le impostazioni per attivare o disattivare il gateway" } }, "ja" : { "stringUnit" : { - "state" : "needs_review", - "value" : "チャンネル設定を開いてゲートウェイのオン/オフを切り替えます" + "state" : "translated", + "value" : "設定を開いてゲートウェイのオン/オフを切り替えます" } }, "ko" : { "stringUnit" : { - "state" : "needs_review", - "value" : "채널 설정을 열어 게이트웨이를 켜거나 끕니다" + "state" : "translated", + "value" : "설정을 열어 게이트웨이를 켜거나 끕니다" } }, "ms" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Membuka tetapan saluran untuk menghidupkan atau mematikan gateway" + "state" : "translated", + "value" : "Membuka tetapan untuk menghidupkan atau mematikan gateway" } }, "ne" : { "stringUnit" : { - "state" : "needs_review", - "value" : "गेटवे खोल्न वा बन्द गर्न च्यानल सेटिङहरू खोल्छ" + "state" : "translated", + "value" : "गेटवे खोल्न वा बन्द गर्न सेटिङहरू खोल्छ" } }, "nl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Opent kanaalinstellingen om de gateway aan of uit te zetten" + "state" : "translated", + "value" : "Opent instellingen om de gateway aan of uit te zetten" } }, "pl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Otwiera ustawienia kanałów, aby włączyć lub wyłączyć bramę" + "state" : "translated", + "value" : "Otwiera ustawienia, aby włączyć lub wyłączyć bramę" } }, "pt" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Abre as definições de canais para ligar ou desligar o gateway" + "state" : "translated", + "value" : "Abre as definições para ligar ou desligar o gateway" } }, "pt-BR" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Abre as configurações de canais para ligar ou desligar o gateway" + "state" : "translated", + "value" : "Abre as configurações para ligar ou desligar o gateway" } }, "ru" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Открывает настройки каналов, чтобы включить или выключить шлюз" + "state" : "translated", + "value" : "Открывает настройки, чтобы включить или выключить шлюз" } }, "sv" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Öppnar kanalinställningar för att slå på eller av gatewayen" + "state" : "translated", + "value" : "Öppnar inställningar för att slå på eller av gatewayen" } }, "ta" : { "stringUnit" : { - "state" : "needs_review", - "value" : "கேட்வேயை இயக்க அல்லது அணைக்க சேனல் அமைப்புகளைத் திறக்கிறது" + "state" : "translated", + "value" : "கேட்வேயை இயக்க அல்லது அணைக்க அமைப்புகளைத் திறக்கிறது" } }, "th" : { "stringUnit" : { - "state" : "needs_review", - "value" : "เปิดการตั้งค่าช่องเพื่อเปิดหรือปิดเกตเวย์" + "state" : "translated", + "value" : "เปิดการตั้งค่าเพื่อเปิดหรือปิดเกตเวย์" } }, "tr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Ağ geçidini açmak veya kapatmak için kanal ayarlarını açar" + "state" : "translated", + "value" : "Ağ geçidini açmak veya kapatmak için ayarları açar" } }, "uk" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Відкриває налаштування каналів, щоб увімкнути або вимкнути шлюз" + "state" : "translated", + "value" : "Відкриває налаштування, щоб увімкнути або вимкнути шлюз" } }, "ur" : { "stringUnit" : { - "state" : "needs_review", - "value" : "گیٹ وے کو آن یا آف کرنے کے لیے چینل کی ترتیبات کھولتا ہے" + "state" : "translated", + "value" : "گیٹ وے کو آن یا آف کرنے کے لیے ترتیبات کھولتا ہے" } }, "vi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Mở cài đặt kênh để bật hoặc tắt gateway" + "state" : "translated", + "value" : "Mở cài đặt để bật hoặc tắt gateway" } }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", - "value" : "打开频道设置以开启或关闭网关" + "state" : "translated", + "value" : "打开设置以开启或关闭网关" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", - "value" : "開啟頻道設定以開啟或關閉閘道" + "state" : "translated", + "value" : "開啟設定以開啟或關閉閘道" } } } @@ -15075,6 +18117,364 @@ } } }, + "content.accessibility.nearby_only_off" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موصول بالجسر: تصل الرسائل أيضًا إلى من هم عبر الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্রিজযুক্ত: বার্তা ব্রিজের ওপারের মানুষের কাছেও পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überbrückt: nachrichten erreichen auch personen über die brücke" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bridged: messages also reach people across the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con puente: los mensajes también llegan a personas al otro lado del puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naka-bridge: umaabot din ang mga mensahe sa mga tao sa kabila ng bridge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pont actif : les messages atteignent aussi les gens de l'autre côté du pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מגושר: ההודעות מגיעות גם לאנשים מעבר לגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्रिज सक्रिय: संदेश ब्रिज के पार के लोगों तक भी पहुँचते हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terjembatani: pesan juga sampai ke orang di seberang jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con ponte: i messaggi raggiungono anche le persone oltre il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジ中: メッセージはブリッジの先の人にも届きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 사용: 메시지가 브리지 너머 사람들에게도 전달됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berjambatan: pesan juga sampai kepada orang di seberang jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल जोडिएको: सन्देशहरू पुलपारिका मानिससम्म पनि पुग्छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overbrugd: berichten bereiken ook mensen via de brug" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmostkowane: wiadomości docierają też do osób po drugiej stronie mostu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte: as mensagens também chegam a pessoas do outro lado da ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через мост: сообщения доходят и до людей по ту сторону моста" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bryggad: meddelanden når även personer via bryggan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலம் இணைந்தது: செய்திகள் பாலத்தின் அப்பாலுள்ளவர்களையும் சென்றடையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บริดจ์อยู่: ข้อความยังไปถึงคนที่อีกฝั่งของบริดจ์ด้วย" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Köprülü: mesajlar köprünün ötesindeki kişilere de ulaşır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через міст: повідомлення доходять і до людей по той бік мосту" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل فعال: پیغامات پل کے پار لوگوں تک بھی پہنچتے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bắc cầu: tin nhắn cũng đến người bên kia cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已桥接:消息也会送达桥另一侧的人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已橋接:訊息也會送達橋另一側的人" + } + } + } + }, + "content.accessibility.nearby_only_on" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قريب فقط: تبقى الرسائل ضمن مدى الراديو" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু কাছাকাছি: বার্তা রেডিও পরিসরের মধ্যেই থাকে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur in der nähe: nachrichten bleiben innerhalb der funkreichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearby only: messages stay within radio range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo cerca: los mensajes se quedan dentro del alcance de radio" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malapit lamang: nananatili ang mga mensahe sa loob ng saklaw ng radyo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proximité uniquement : les messages restent à portée radio" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קרוב בלבד: ההודעות נשארות בטווח הרדיו" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल आसपास: संदेश रेडियो दायरे के भीतर रहते हैं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya terdekat: pesan tetap dalam jangkauan radio" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo vicinanze: i messaggi restano entro la portata radio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのみ: メッセージは電波範囲内にとどまります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 전용: 메시지가 전파 범위 안에만 머뭅니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berdekatan sahaja: pesan kekal dalam jangkauan radio" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिक मात्र: सन्देशहरू रेडियो पहुँचभित्रै रहन्छन्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen dichtbij: berichten blijven binnen radiobereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tylko w pobliżu: wiadomości pozostają w zasięgu radiowym" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto: as mensagens ficam dentro do alcance de rádio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только рядом: сообщения остаются в радиусе радиосвязи" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endast i närheten: meddelanden stannar inom radioräckvidd" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் மட்டும்: செய்திகள் ரேடியோ வரம்புக்குள் இருக்கும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะใกล้เคียง: ข้อความอยู่ภายในระยะวิทยุ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca yakın: mesajlar telsiz menzili içinde kalır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лише поблизу: повідомлення залишаються в радіусі радіозв'язку" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف قریبی: پیغامات ریڈیو رینج کے اندر رہتے ہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ gần đây: tin nhắn ở trong phạm vi sóng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅限附近:消息只在无线电范围内传播" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅限附近:訊息只在無線電範圍內傳播" + } + } + } + }, "content.accessibility.notices" : { "comment" : "Accessibility label for the notices button", "extractionState" : "manual", @@ -24605,6 +28005,364 @@ } } }, + "content.composer.nearby_only_off" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موصول بالجسر — تصل إلى أشخاص خارج مدى الراديو في هذه المنطقة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ব্রিজযুক্ত — এই এলাকায় রেডিও পরিসরের বাইরের মানুষের কাছেও পৌঁছায়" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überbrückt — erreicht personen außerhalb der funkreichweite in dieser gegend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bridged — reaches people beyond radio range in this area" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con puente — llega a personas fuera del alcance de radio en esta zona" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naka-bridge — umaabot sa mga taong lampas sa saklaw ng radyo sa lugar na ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pont actif — atteint les gens hors de portée radio dans cette zone" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מגושר — מגיעה לאנשים מעבר לטווח הרדיו באזור הזה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ब्रिज सक्रिय — इस क्षेत्र में रेडियो दायरे से बाहर के लोगों तक पहुँचता है" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terjembatani — sampai ke orang di luar jangkauan radio di area ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con ponte — raggiunge persone oltre la portata radio in questa zona" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブリッジ中 — このエリアの電波範囲外の人にも届きます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "브리지 사용 — 이 지역의 전파 범위 밖 사람들에게도 전달됩니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berjambatan — sampai kepada orang di luar jangkauan radio di kawasan ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पुल जोडिएको — यस क्षेत्रमा रेडियो पहुँचभन्दा बाहिरका मानिससम्म पुग्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overbrugd — bereikt mensen buiten radiobereik in deze omgeving" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmostkowane — dociera do osób poza zasięgiem radiowym w tej okolicy" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta zona" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com ponte — chega a pessoas fora do alcance de rádio nesta área" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через мост — доходит до людей за пределами радиуса радиосвязи в этом районе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bryggad — når personer utom radioräckvidd i det här området" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பாலம் இணைந்தது — இந்தப் பகுதியில் ரேடியோ வரம்புக்கு அப்பாலுள்ளவர்களைச் சென்றடையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บริดจ์อยู่ — ไปถึงคนที่อยู่นอกระยะวิทยุในพื้นที่นี้" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Köprülü — bu bölgedeki telsiz menzili dışındaki kişilere ulaşır" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Через міст — доходить до людей поза радіусом радіозв'язку в цьому районі" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پل فعال — اس علاقے میں ریڈیو رینج سے باہر لوگوں تک پہنچتا ہے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã bắc cầu — đến được người ngoài phạm vi sóng trong khu vực này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已桥接 — 可送达本地区无线电范围之外的人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已橋接 — 可送達本地區無線電範圍之外的人" + } + } + } + }, + "content.composer.nearby_only_on" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قريب فقط — لن تعبر هذه الرسالة الجسر" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "শুধু কাছাকাছি — এই বার্তা ব্রিজ পার হবে না" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur in der nähe — diese nachricht überquert die brücke nicht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearby only — this message won't cross the bridge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo cerca — este mensaje no cruzará el puente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malapit lamang — hindi tatawid sa bridge ang mensaheng ito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proximité uniquement — ce message ne franchira pas le pont" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קרוב בלבד — ההודעה הזו לא תחצה את הגשר" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "केवल आसपास — यह संदेश ब्रिज पार नहीं करेगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hanya terdekat — pesan ini tidak akan menyeberangi jembatan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo vicinanze — questo messaggio non attraverserà il ponte" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "近くのみ — このメッセージはブリッジを越えません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "근처 전용 — 이 메시지는 브리지를 건너지 않습니다" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berdekatan sahaja — pesan ini tidak akan menyeberangi jambatan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "नजिक मात्र — यो सन्देश पुल तर्दैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen dichtbij — dit bericht gaat de brug niet over" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tylko w pobliżu — ta wiadomość nie przejdzie przez most" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto — esta mensagem não vai atravessar a ponte" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Só por perto — esta mensagem não vai atravessar a ponte" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только рядом — это сообщение не пересечёт мост" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endast i närheten — det här meddelandet korsar inte bryggan" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அருகில் மட்டும் — இந்தச் செய்தி பாலத்தைத் தாண்டாது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เฉพาะใกล้เคียง — ข้อความนี้จะไม่ข้ามบริดจ์" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yalnızca yakın — bu mesaj köprüyü geçmeyecek" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лише поблизу — це повідомлення не перетне міст" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صرف قریبی — یہ پیغام پل پار نہیں کرے گا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ gần đây — tin nhắn này sẽ không qua cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅限附近 — 这条消息不会跨越桥接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "僅限附近 — 這則訊息不會跨越橋接" + } + } + } + }, "content.delivery.delivered_members" : { "extractionState" : "manual", "localizations" : { @@ -25327,175 +29085,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "المستخدم محظور" + "value" : "هذا الشخص محظور" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "ব্যবহারকারী ব্লক করা" + "value" : "এই ব্যক্তি ব্লক করা" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "nutzer blockiert" + "value" : "diese person ist blockiert" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "user is blocked" + "value" : "this person is blocked" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "el usuario está bloqueado" + "value" : "esta persona está bloqueada" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "na-block ang user" + "value" : "naka-block ang taong ito" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "utilisateur bloqué" + "value" : "cette personne est bloquée" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "המשתמש חסום" + "value" : "האדם הזה חסום" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "उपयोगकर्ता ब्लॉक है" + "value" : "यह व्यक्ति ब्लॉक है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "pengguna diblokir" + "value" : "orang ini diblokir" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "utente bloccato" + "value" : "questa persona è bloccata" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ユーザーをブロック中" + "value" : "この人をブロック中" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "차단된 사용자입니다" + "value" : "차단된 사람입니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "pengguna diblokir" + "value" : "orang ini diblokir" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "प्रयोगकर्ता ब्लक गरिएको" + "value" : "यो व्यक्ति ब्लक गरिएको छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "gebruiker is geblokkeerd" + "value" : "deze persoon is geblokkeerd" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "użytkownik zablokowany" + "value" : "ta osoba jest zablokowana" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "utilizador bloqueado" + "value" : "esta pessoa está bloqueada" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "usuário bloqueado" + "value" : "esta pessoa está bloqueada" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "пользователь заблокирован" + "value" : "этот человек заблокирован" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "användaren är blockerad" + "value" : "den här personen är blockerad" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "பயனர் தடுக்கப்பட்டுள்ளார்" + "value" : "இந்த நபர் தடுக்கப்பட்டுள்ளார்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "ผู้ใช้ถูกบล็อก" + "value" : "คนนี้ถูกบล็อก" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "kullanıcı engellendi" + "value" : "bu kişi engellendi" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "користувач заблокований" + "value" : "цю людину заблоковано" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "صارف بلاک ہے" + "value" : "یہ شخص بلاک ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "người dùng bị chặn" + "value" : "người này bị chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "用户已被屏蔽" + "value" : "此人已被屏蔽" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用者已被屏蔽" + "value" : "此人已被屏蔽" } } } @@ -37002,24 +40760,6 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "zero" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d أشخاص" - } - }, - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخص" - } - }, - "two" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d شخصان" - } - }, "few" : { "stringUnit" : { "state" : "needs_review", @@ -37032,11 +40772,29 @@ "value" : "%d شخصًا" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخص" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", "value" : "%d شخص" } + }, + "two" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d شخصان" + } + }, + "zero" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d أشخاص" + } } } } @@ -37212,29 +40970,29 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אדם" - } - }, - "two" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d אנשים" - } - }, "many" : { "stringUnit" : { "state" : "needs_review", "value" : "%d אנשים" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אדם" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", "value" : "%d אנשים" } + }, + "two" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d אנשים" + } } } } @@ -37444,12 +41202,6 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d osobę" - } - }, "few" : { "stringUnit" : { "state" : "needs_review", @@ -37462,6 +41214,12 @@ "value" : "%d osób" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d osobę" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", @@ -37676,12 +41434,6 @@ "formatSpecifier" : "lld", "variations" : { "plural" : { - "one" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "%d особа" - } - }, "few" : { "stringUnit" : { "state" : "needs_review", @@ -37694,6 +41446,12 @@ "value" : "%d осіб" } }, + "one" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "%d особа" + } + }, "other" : { "stringUnit" : { "state" : "needs_review", @@ -42640,362 +46398,182 @@ } } }, - "location_channels.gateway.subtitle" : { - "comment" : "Explanation under the internet gateway toggle", + "location_channels.grant_to_find" : { + "comment" : "Hint shown in the channel list instead of a loading spinner when location permission is missing", "extractionState" : "manual", "localizations" : { "ar" : { "stringUnit" : { - "state" : "needs_review", - "value" : "شارك إنترنتك مع أقران mesh القريبين لتصل رسائل geohash الخاصة بهم إلى الشبكة" + "state" : "translated", + "value" : "امنح إذن الموقع للعثور على القنوات القريبة" } }, "bn" : { "stringUnit" : { - "state" : "needs_review", - "value" : "কাছাকাছি মেশ পিয়ারদের সঙ্গে আপনার ইন্টারনেট ভাগ করুন যাতে তাদের জিওহ্যাশ বার্তা নেটওয়ার্কে পৌঁছায়" + "state" : "translated", + "value" : "কাছাকাছি চ্যানেল খুঁজতে অবস্থানের অনুমতি দিন" } }, "de" : { "stringUnit" : { - "state" : "needs_review", - "value" : "teile dein internet mit nahen mesh-peers, damit ihre geohash-nachrichten das netzwerk erreichen" + "state" : "translated", + "value" : "standortzugriff erlauben, um kanäle in der nähe zu finden" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "share your internet with nearby mesh peers so their geohash messages reach the network" + "value" : "grant location access to find nearby channels" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "comparte tu internet con peers cercanos del mesh para que sus mensajes geohash lleguen a la red" + "value" : "concede acceso a la ubicación para encontrar canales cercanos" } }, "fil" : { "stringUnit" : { - "state" : "needs_review", - "value" : "ibahagi ang iyong internet sa mga kalapit na mesh peer para makarating sa network ang kanilang mga geohash na mensahe" + "state" : "translated", + "value" : "payagan ang access sa lokasyon para makahanap ng mga kalapit na channel" } }, "fr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "partage ton internet avec les pairs mesh à proximité pour que leurs messages geohash atteignent le réseau" + "state" : "translated", + "value" : "autorisez l'accès à la position pour trouver les canaux à proximité" } }, "he" : { "stringUnit" : { - "state" : "needs_review", - "value" : "שתף את האינטרנט שלך עם עמיתי mesh קרובים כדי שהודעות ה-geohash שלהם יגיעו לרשת" + "state" : "translated", + "value" : "אפשרו גישה למיקום כדי למצוא ערוצים בקרבת מקום" } }, "hi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "अपना इंटरनेट आसपास के मेश पीयरों के साथ साझा करें ताकि उनके जियोहैश संदेश नेटवर्क तक पहुँचें" + "state" : "translated", + "value" : "आस-पास के चैनल खोजने के लिए स्थान की अनुमति दें" } }, "id" : { "stringUnit" : { - "state" : "needs_review", - "value" : "bagikan internetmu ke peer mesh terdekat agar pesan geohash mereka sampai ke jaringan" + "state" : "translated", + "value" : "izinkan akses lokasi untuk menemukan channel terdekat" } }, "it" : { "stringUnit" : { - "state" : "needs_review", - "value" : "condividi il tuo internet con i peer mesh vicini così i loro messaggi geohash raggiungono la rete" + "state" : "translated", + "value" : "consenti l'accesso alla posizione per trovare i canali vicini" } }, "ja" : { "stringUnit" : { - "state" : "needs_review", - "value" : "近くのmeshピアとインターネットを共有して、そのgeohashメッセージをネットワークに届けます" + "state" : "translated", + "value" : "近くのチャンネルを見つけるには位置情報へのアクセスを許可してください" } }, "ko" : { "stringUnit" : { - "state" : "needs_review", - "value" : "근처 mesh 피어와 인터넷을 공유하여 그들의 geohash 메시지가 네트워크에 도달하도록 합니다" + "state" : "translated", + "value" : "주변 채널을 찾으려면 위치 접근을 허용하세요" } }, "ms" : { "stringUnit" : { - "state" : "needs_review", - "value" : "kongsi internetmu dengan peer mesh berdekatan supaya pesan geohash mereka sampai ke rangkaian" + "state" : "translated", + "value" : "benarkan akses lokasi untuk mencari saluran berdekatan" } }, "ne" : { "stringUnit" : { - "state" : "needs_review", - "value" : "नजिकका mesh सहकर्मीसँग तिम्रो इन्टरनेट साझेदारी गर ताकि उनीहरूका geohash सन्देश नेटवर्कसम्म पुगून्" + "state" : "translated", + "value" : "नजिकैका च्यानलहरू फेला पार्न स्थान पहुँच दिनुहोस्" } }, "nl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "deel je internet met mesh-peers in de buurt zodat hun geohash-berichten het netwerk bereiken" + "state" : "translated", + "value" : "geef locatietoegang om kanalen in de buurt te vinden" } }, "pl" : { "stringUnit" : { - "state" : "needs_review", - "value" : "udostępnij internet pobliskim peerom mesh, aby ich wiadomości geohash docierały do sieci" + "state" : "translated", + "value" : "przyznaj dostęp do lokalizacji, aby znaleźć pobliskie kanały" } }, "pt" : { "stringUnit" : { - "state" : "needs_review", - "value" : "partilha a tua internet com pares mesh próximos para que as mensagens geohash deles cheguem à rede" + "state" : "translated", + "value" : "conceda acesso à localização para encontrar canais próximos" } }, "pt-BR" : { "stringUnit" : { - "state" : "needs_review", - "value" : "compartilhe sua internet com pares do mesh por perto para que as mensagens geohash deles cheguem à rede" + "state" : "translated", + "value" : "conceda acesso à localização para encontrar canais próximos" } }, "ru" : { "stringUnit" : { - "state" : "needs_review", - "value" : "раздай интернет ближайшим mesh-пирам, чтобы их geohash-сообщения дошли до сети" + "state" : "translated", + "value" : "разрешите доступ к геопозиции, чтобы найти каналы поблизости" } }, "sv" : { "stringUnit" : { - "state" : "needs_review", - "value" : "dela ditt internet med mesh-peers i närheten så att deras geohash-meddelanden når nätverket" + "state" : "translated", + "value" : "ge platsåtkomst för att hitta kanaler i närheten" } }, "ta" : { "stringUnit" : { - "state" : "needs_review", - "value" : "உங்கள் இணையத்தை அருகிலுள்ள mesh peer-களுடன் பகிர்ந்து அவர்களின் geohash செய்திகள் நெட்வொர்க்கை அடையச் செய்யவும்" + "state" : "translated", + "value" : "அருகிலுள்ள சேனல்களைக் கண்டறிய இருப்பிட அணுகலை வழங்கவும்" } }, "th" : { "stringUnit" : { - "state" : "needs_review", - "value" : "แชร์อินเทอร์เน็ตของคุณกับเพียร์ mesh ที่อยู่ใกล้เพื่อให้ข้อความ geohash ของพวกเขาไปถึงเครือข่าย" + "state" : "translated", + "value" : "อนุญาตการเข้าถึงตำแหน่งเพื่อค้นหาช่องใกล้เคียง" } }, "tr" : { "stringUnit" : { - "state" : "needs_review", - "value" : "geohash mesajlarının ağa ulaşması için internetini yakındaki mesh eşleriyle paylaş" + "state" : "translated", + "value" : "yakındaki kanalları bulmak için konum erişimine izin verin" } }, "uk" : { "stringUnit" : { - "state" : "needs_review", - "value" : "поділися інтернетом з ближніми пірами mesh, щоб їхні повідомлення geohash досягали мережі" + "state" : "translated", + "value" : "надайте доступ до геолокації, щоб знайти канали поблизу" } }, "ur" : { "stringUnit" : { - "state" : "needs_review", - "value" : "قریبی mesh ہم منصبوں کے ساتھ اپنا انٹرنیٹ شیئر کریں تاکہ ان کے geohash پیغامات نیٹ ورک تک پہنچیں" + "state" : "translated", + "value" : "قریبی چینلز تلاش کرنے کے لیے مقام تک رسائی دیں" } }, "vi" : { "stringUnit" : { - "state" : "needs_review", - "value" : "chia sẻ internet của bạn với các nút mesh gần đó để tin nhắn geohash của họ đến được mạng" + "state" : "translated", + "value" : "cấp quyền truy cập vị trí để tìm các kênh gần đây" } }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", - "value" : "与附近 mesh 同伴共享你的互联网,让他们的 geohash 消息能到达网络" + "state" : "translated", + "value" : "授予位置权限以查找附近的频道" } }, "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "與附近的 mesh 同伴分享你的網際網路,讓他們的 geohash 訊息能傳到網路上" - } - } - } - }, - "location_channels.gateway.title" : { - "comment" : "Title for the internet gateway toggle in the location channels sheet", - "extractionState" : "manual", - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "بوابة الإنترنت" - } - }, - "bn" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "ইন্টারনেট গেটওয়ে" - } - }, - "de" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet-gateway" - } - }, - "en" : { "stringUnit" : { "state" : "translated", - "value" : "internet gateway" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "puerta de enlace a internet" - } - }, - "fil" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet gateway" - } - }, - "fr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "passerelle internet" - } - }, - "he" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "שער אינטרנט" - } - }, - "hi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इंटरनेट गेटवे" - } - }, - "id" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "it" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "ja" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "インターネットゲートウェイ" - } - }, - "ko" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "인터넷 게이트웨이" - } - }, - "ms" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway internet" - } - }, - "ne" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "इन्टरनेट गेटवे" - } - }, - "nl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway" - } - }, - "pl" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "brama internetowa" - } - }, - "pt" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway de internet" - } - }, - "pt-BR" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "gateway de internet" - } - }, - "ru" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "интернет-шлюз" - } - }, - "sv" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internetgateway" - } - }, - "ta" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "இணைய நுழைவாயில்" - } - }, - "th" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "เกตเวย์อินเทอร์เน็ต" - } - }, - "tr" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "internet ağ geçidi" - } - }, - "uk" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "інтернет-шлюз" - } - }, - "ur" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "انٹرنیٹ گیٹ وے" - } - }, - "vi" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "cổng internet" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "互联网网关" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "needs_review", - "value" : "網際網路閘道" + "value" : "授予位置權限以尋找附近的頻道" } } } @@ -46449,6 +50027,12 @@ "value" : "verbinde mit relays…" } }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connecting to relays…" + } + }, "es" : { "stringUnit" : { "state" : "translated", @@ -46598,12 +50182,6 @@ "state" : "translated", "value" : "正在連接中繼…" } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "connecting to relays…" - } } } }, diff --git a/bitchat/Nostr/NostrIdentityBridge.swift b/bitchat/Nostr/NostrIdentityBridge.swift index c91ffc42..2e18a235 100644 --- a/bitchat/Nostr/NostrIdentityBridge.swift +++ b/bitchat/Nostr/NostrIdentityBridge.swift @@ -86,6 +86,13 @@ final class NostrIdentityBridge { return seed } + /// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge + /// rendezvous cell. Distinct HMAC label keeps it unlinkable from the + /// geohash-chat identity for the same cell string. + func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity { + try deriveIdentity(forGeohash: "bridge|" + cell) + } + /// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// if the candidate is not a valid secp256k1 private key. diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 80c57f14..bdcb60f5 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -20,6 +20,10 @@ struct NostrProtocol { case ephemeralEvent = 20000 case geohashPresence = 20001 case deletion = 5 // NIP-09 event deletion request + /// Sealed courier envelope parked on relays under its rotating + /// recipient tag (`#x`). Regular (stored) kind so it survives until + /// its NIP-40 expiration — the whole point is store-and-forward. + case courierDrop = 1401 } /// Create a NIP-17 private message @@ -256,6 +260,84 @@ struct NostrProtocol { return try event.sign(with: schnorrKey) } + // MARK: - Mesh bridge (rendezvous) events + + /// Create a mesh-bridge public message (kind 20000) for a geohash-cell + /// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash + /// channel subscriptions (which filter on `#g`); `m` carries the original + /// mesh message ID so receivers dedup the bridged copy against the radio + /// copy by timeline ID. + static func createBridgeMeshEvent( + content: String, + cell: String, + senderIdentity: NostrIdentity, + nickname: String? = nil, + meshMessageID: String? = nil + ) throws -> NostrEvent { + var tags = [["r", cell]] + if let nickname = nickname?.trimmedOrNilIfEmpty { + tags.append(["n", nickname]) + } + if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty { + tags.append(["m", meshMessageID]) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous + /// cell: empty content, `r` tag only — the bridge analogue of geohash + /// presence, counted into "people across the bridge". + static func createBridgePresenceEvent( + cell: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["r", cell]], + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + + /// Create a courier drop (kind 1401): an opaque sealed courier envelope + /// parked on relays. `x` is the hex recipient tag the recipient (or a + /// gateway acting for them) subscribes for; the NIP-40 expiration tracks + /// the envelope expiry so honoring relays garbage-collect the drop. The + /// signing identity should be a throwaway — the envelope authenticates + /// its sender internally via Noise-X, and linking drops to a stable + /// publisher key would leak courier traffic patterns. + static func createCourierDropEvent( + envelope: Data, + recipientTagHex: String, + expiresAt: Date, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let tags = [ + ["x", recipientTagHex], + ["expiration", String(Int(expiresAt.timeIntervalSince1970))], + ] + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .courierDrop, + tags: tags, + content: envelope.base64EncodedString() + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays /// drop the note in step with a bridged board post's expiry. diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 63583cc7..88da5dff 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -1510,6 +1510,29 @@ struct NostrFilter: Encodable { filter.limit = limit return filter } + + // For the mesh bridge: rendezvous messages (kind 20000) and presence + // (kind 20001) tagged `#r` with one or more cells (own + neighbors). + static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [20000, 20001] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["r": cells] + filter.limit = limit + return filter + } + + // For courier drops: sealed envelopes (kind 1401) parked under rotating + // recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent + // UTC days x recipients) in one filter. + static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["x": recipientTagsHex] + filter.limit = limit + return filter + } } // Dynamic coding key for tag filters diff --git a/bitchat/Protocols/NostrCarrierPacket.swift b/bitchat/Protocols/NostrCarrierPacket.swift index e65f2b66..8e7b7c06 100644 --- a/bitchat/Protocols/NostrCarrierPacket.swift +++ b/bitchat/Protocols/NostrCarrierPacket.swift @@ -32,6 +32,14 @@ struct NostrCarrierPacket: Equatable { enum Direction: UInt8 { case toGateway = 0x01 case fromGateway = 0x02 + /// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to + /// publish its signed rendezvous event. Directed, like `toGateway`. + case toBridge = 0x03 + /// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous + /// event from a remote island. Broadcast, like `fromGateway`. + /// Old clients fail the Direction decode on 0x03/0x04 and drop the + /// carrier quietly — bridge traffic degrades to invisible, not junk. + case fromBridge = 0x04 } let direction: Direction diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 309860aa..1f691dd4 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -9,19 +9,25 @@ struct AnnouncementPacket { let signingPublicKey: Data // Ed25519 public key for signing let directNeighbors: [Data]? // 8-byte peer IDs let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients) + /// Rendezvous geohash cell this peer bridges, when advertising `.bridge`. + /// Coarse (cell-level) by design; lets mesh-only peers compose correctly + /// tagged rendezvous events without their own location fix. + let bridgeGeohash: String? init( nickname: String, noisePublicKey: Data, signingPublicKey: Data, directNeighbors: [Data]?, - capabilities: PeerCapabilities? = nil + capabilities: PeerCapabilities? = nil, + bridgeGeohash: String? = nil ) { self.nickname = nickname self.noisePublicKey = noisePublicKey self.signingPublicKey = signingPublicKey self.directNeighbors = directNeighbors self.capabilities = capabilities + self.bridgeGeohash = bridgeGeohash } private enum TLVType: UInt8 { @@ -30,6 +36,7 @@ struct AnnouncementPacket { case signingPublicKey = 0x03 case directNeighbors = 0x04 case capabilities = 0x05 + case bridgeGeohash = 0x06 } func encode() -> Data? { @@ -74,6 +81,15 @@ struct AnnouncementPacket { data.append(capabilityBytes) } + // TLV for bridge rendezvous cell (optional; old clients skip it) + if let bridgeGeohash = bridgeGeohash, + let cellData = bridgeGeohash.data(using: .utf8), + !cellData.isEmpty, cellData.count <= 12 { + data.append(TLVType.bridgeGeohash.rawValue) + data.append(UInt8(cellData.count)) + data.append(cellData) + } + return data } @@ -84,6 +100,7 @@ struct AnnouncementPacket { var signingPublicKey: Data? var directNeighbors: [Data]? var capabilities: PeerCapabilities? + var bridgeGeohash: String? while offset + 2 <= data.count { let typeRaw = data[offset] @@ -116,6 +133,10 @@ struct AnnouncementPacket { } case .capabilities: capabilities = PeerCapabilities(encoded: Data(value)) + case .bridgeGeohash: + if length <= 12 { + bridgeGeohash = String(data: value, encoding: .utf8) + } } } else { // Unknown TLV; skip (tolerant decoder for forward compatibility) @@ -129,7 +150,8 @@ struct AnnouncementPacket { noisePublicKey: noisePublicKey, signingPublicKey: signingPublicKey, directNeighbors: directNeighbors, - capabilities: capabilities + capabilities: capabilities, + bridgeGeohash: bridgeGeohash ) } } diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index dc714d13..9195d147 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -10,6 +10,8 @@ struct BLEPeerInfo: Equatable { var isVerifiedNickname: Bool var lastSeen: Date var capabilities: PeerCapabilities = [] + /// Rendezvous cell from the peer's announce when it advertises `.bridge`. + var bridgeGeohash: String? } struct BLEPeerAnnounceUpdate: Equatable { @@ -117,6 +119,15 @@ struct BLEPeerRegistry { peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) } + /// A rendezvous cell advertised by any bridge-capable peer, if one is + /// known — lets location-less devices join the island's rendezvous. + func advertisedBridgeGeohash() -> String? { + peers.values + .filter { $0.capabilities.contains(.bridge) } + .compactMap(\.bridgeGeohash) + .first + } + func displayNicknames(selfNickname: String) -> [PeerID: String] { let connected = peers.filter { $0.value.isConnected } let tuples = connected.map { ($0.key, $0.value.nickname, true) } @@ -160,7 +171,8 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date, - capabilities: PeerCapabilities = [] + capabilities: PeerCapabilities = [], + bridgeGeohash: String? = nil ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] let update = BLEPeerAnnounceUpdate( @@ -177,7 +189,8 @@ struct BLEPeerRegistry { signingPublicKey: signingPublicKey, isVerifiedNickname: true, lastSeen: now, - capabilities: capabilities + capabilities: capabilities, + bridgeGeohash: bridgeGeohash ) return update diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 5050ea2c..19eb6a32 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -84,7 +84,11 @@ final class BLEService: NSObject { // for every announce. `directedToUs` distinguishes an uplink deposit // addressed to this device from a downlink broadcast. var onNostrCarrierPacket: (@MainActor (_ payload: Data, _ from: PeerID, _ directedToUs: Bool) -> Void)? + /// Fired (off-main) when a signature-verified announce is processed — + /// the bridge courier watch refreshes its tag set on new arrivals. + var onVerifiedPeerAnnounce: ((_ peerID: PeerID) -> Void)? private var runtimeCapabilities: PeerCapabilities = [] // collectionsQueue + private var localBridgeGeohash: String? // collectionsQueue #if DEBUG // Test-only tap on the outbound pipeline so multi-node tests can ferry @@ -708,6 +712,33 @@ final class BLEService: NSObject { } } + /// Reachable peers currently advertising the `.bridge` capability. + func reachableBridgePeers() -> [PeerID] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.peers(advertising: .bridge) + .filter { peerRegistry.isReachable($0, now: now) } + } + } + + /// A rendezvous cell advertised by a bridge-capable peer's announce. + func advertisedBridgeGeohash() -> String? { + collectionsQueue.sync { peerRegistry.advertisedBridgeGeohash() } + } + + /// The rendezvous cell this device advertises in its own announces while + /// bridging with the gateway toggle on. Set from the main actor; the + /// value rides the next (forced) announce. + func setLocalBridgeGeohash(_ cell: String?) { + let changed: Bool = collectionsQueue.sync(flags: .barrier) { + guard localBridgeGeohash != cell else { return false } + localBridgeGeohash = cell + return true + } + guard changed else { return } + sendAnnounce(forceSend: true) + } + func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { peerRegistry.displayNicknames(selfNickname: myNickname) @@ -1395,8 +1426,12 @@ final class BLEService: NSObject { let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification let signingPub = noiseService.getSigningPublicKeyData() // For signature verification - let (connectedPeerIDs, advertisedCapabilities): ([Data], PeerCapabilities) = collectionsQueue.sync { - (peerRegistry.connectedRoutingData, PeerCapabilities.localSupported.union(runtimeCapabilities)) + let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync { + ( + peerRegistry.connectedRoutingData, + PeerCapabilities.localSupported.union(runtimeCapabilities), + runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil + ) } let announcement = AnnouncementPacket( @@ -1404,7 +1439,8 @@ final class BLEService: NSObject { noisePublicKey: noisePub, signingPublicKey: signingPub, directNeighbors: connectedPeerIDs, - capabilities: advertisedCapabilities + capabilities: advertisedCapabilities, + bridgeGeohash: advertisedBridgeCell ) guard let payload = announcement.encode() else { @@ -3066,6 +3102,92 @@ extension BLEService { return true } + // MARK: Courier over the bridge + + /// Seals `content` into a courier envelope for relay parking (a bridge + /// courier drop). Same sealing rules as `sendCourierMessage` — prekey + /// (v2) when a verified bundle is cached, static Noise X (v1) otherwise — + /// but carry-only: a relay copy never sprays. + func sealBridgeCourierEnvelope(_ content: String, messageID: String, recipientNoiseKey: Data) -> CourierEnvelope? { + guard let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + return nil + } + do { + let now = Date() + let sealed: Data + let prekeyID: UInt32? + if let prekey = assignRecipientPrekey(messageID: messageID, recipientNoiseKey: recipientNoiseKey) { + sealed = try noiseService.sealPrekeyPayload(typedPayload, recipientPrekey: prekey) + prekeyID = prekey.id + } else { + sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + prekeyID = nil + } + return CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed, + copies: 1, + prekeyID: prekeyID + ) + } catch { + SecureLogger.error("Failed to seal bridge courier envelope: \(error)", category: .encryption) + return nil + } + } + + /// Opens a courier envelope that arrived as a bridge drop (relay fetch, + /// not a directed mesh packet). Returns false when the rotating tag does + /// not match our static key — a drop for someone else, or a stale tag. + /// The inner Noise X seal authenticates the sender; there is no packet + /// signature to check on this path. + @discardableResult + func openBridgedCourierEnvelope(_ envelope: CourierEnvelope) -> Bool { + guard !envelope.isExpired else { return false } + let myKey = noiseService.getStaticPublicKeyData() + guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else { + return false + } + openCourierEnvelope(envelope) + return true + } + + /// Hands a bridge-fetched envelope directly to the matching local peer + /// as a directed courier packet. Delivery-only by design: the recipient's + /// tag matched, so this never lands in a stranger's carry quota. + @discardableResult + func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool { + guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false } + guard let payload = envelope.encode() else { return false } + let packet = makeCourierPacket(payload, to: peerID) + messageQueue.async { [weak self] in + self?.sendPacketDirected(packet, to: peerID) + } + return true + } + + /// Our own Noise static public key (for computing our courier tags). + func myNoiseStaticPublicKey() -> Data { + noiseService.getStaticPublicKeyData() + } + + /// Verified reachable peers with known Noise keys — the set a bridge + /// gateway watches courier drops for. + func verifiedPeersWithNoiseKeys() -> [(peerID: PeerID, noiseKey: Data)] { + let now = Date() + return collectionsQueue.sync { + peerRegistry.snapshotByID.values.compactMap { info in + guard info.isVerifiedNickname, + let key = info.noisePublicKey, + peerRegistry.isReachable(info.peerID, now: now) else { return nil } + return (info.peerID, key) + } + } + } + /// The prekey to seal a courier message with, or nil to fall back to /// static sealing. The real signal is a verified, unexpired bundle with a /// spare prekey; the advertised `.prekeys` capability only acts as a veto @@ -4185,6 +4307,12 @@ extension BLEService { rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID) } + // Bridge courier watch: a verified announce may add a peer whose + // relay-parked drops we should start watching for. + if let result, result.isVerified { + onVerifiedPeerAnnounce?(result.peerID) + } + // Courier work: an announce is the moment we learn a peer's Noise // static key, so check whether we're carrying mail addressed to them // (or spray-able mail they could carry). Verified announces only. @@ -4314,7 +4442,8 @@ extension BLEService { signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, now: now, - capabilities: announcement.capabilities ?? [] + capabilities: announcement.capabilities ?? [], + bridgeGeohash: announcement.bridgeGeohash ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, shouldEmitReconnectLog: { [weak self] peerID, now in diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift index 06bfc7c1..b765edc5 100644 --- a/bitchat/Services/Courier/CourierStore.swift +++ b/bitchat/Services/Courier/CourierStore.swift @@ -42,6 +42,8 @@ final class CourierStore { var sprayedTo: Set /// Last speculative multi-hop handover toward a relayed announce. var lastRemoteHandoverAt: Date? + /// Last publish of this envelope as a bridge courier drop on relays. + var lastBridgePublishAt: Date? /// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1. let prekeyID: UInt32? @@ -59,6 +61,7 @@ final class CourierStore { copies: UInt8, sprayedTo: Set = [], lastRemoteHandoverAt: Date? = nil, + lastBridgePublishAt: Date? = nil, prekeyID: UInt32? = nil ) { self.recipientTag = recipientTag @@ -70,6 +73,7 @@ final class CourierStore { self.copies = copies self.sprayedTo = sprayedTo self.lastRemoteHandoverAt = lastRemoteHandoverAt + self.lastBridgePublishAt = lastBridgePublishAt self.prekeyID = prekeyID } @@ -86,6 +90,7 @@ final class CourierStore { copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1 sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? [] lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt) + lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt) prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID) } } @@ -242,6 +247,30 @@ final class CourierStore { } } + /// Envelopes to park on relays as bridge courier drops. Non-destructive + /// like remote handover — the relay copy is speculative, so the carried + /// copy stays until direct handover or expiry. The per-envelope cooldown + /// keeps relay churn from republishing the same mail; publishing relays + /// dedup identical events by ID anyway. + func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] { + let date = now() + return queue.sync { + pruneExpiredLocked(at: date) + var matched: [CourierEnvelope] = [] + for index in envelopes.indices { + if let last = envelopes[index].lastBridgePublishAt, + date.timeIntervalSince(last) < cooldown { + continue + } + envelopes[index].lastBridgePublishAt = date + // The relay copy carries no spray budget. + matched.append(envelopes[index].envelope.withCopies(1)) + } + if !matched.isEmpty { persistLocked() } + return matched + } + } + // MARK: - Spray-and-wait (on encountering another courier) /// Envelopes to re-deposit with a courier we just encountered, each with diff --git a/bitchat/Services/Gateway/BoundedIDSet.swift b/bitchat/Services/Gateway/BoundedIDSet.swift new file mode 100644 index 00000000..8509c295 --- /dev/null +++ b/bitchat/Services/Gateway/BoundedIDSet.swift @@ -0,0 +1,35 @@ +// +// BoundedIDSet.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +/// Insertion-ordered string set with a fixed capacity; the oldest entry is +/// evicted when full. Shared by the gateway and bridge loop-prevention +/// caches. +struct BoundedIDSet { + private var members: Set = [] + private var order: [String] = [] + let capacity: Int + + init(capacity: Int) { + self.capacity = capacity + } + + func contains(_ id: String) -> Bool { + members.contains(id) + } + + /// Returns false when the ID was already present. + @discardableResult + mutating func insert(_ id: String) -> Bool { + guard members.insert(id).inserted else { return false } + order.append(id) + if order.count > capacity { + members.remove(order.removeFirst()) + } + return true + } +} diff --git a/bitchat/Services/Gateway/BridgeCourierService.swift b/bitchat/Services/Gateway/BridgeCourierService.swift new file mode 100644 index 00000000..164249d0 --- /dev/null +++ b/bitchat/Services/Gateway/BridgeCourierService.swift @@ -0,0 +1,284 @@ +// +// BridgeCourierService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Foundation +import Security + +/// Courier delivery over the internet bridge: sealed courier envelopes are +/// parked on relays as kind-1401 "drops" tagged with their rotating +/// recipient tag, so delivery stops requiring a physical courier to bump +/// into the recipient. +/// +/// Three duties, all gated on the bridge toggle: +/// - Sender: when the message router seals mail for an unreachable peer, a +/// copy is published as a drop (queued until relays connect). The drop is +/// signed with a fresh throwaway key per publish — the envelope +/// authenticates its sender internally via Noise-X, and a stable publisher +/// key would leak courier traffic patterns to relays. +/// - Recipient: subscribes for its own candidate tags (adjacent UTC days) +/// and opens matching drops directly. +/// - Gateway (bridge + gateway toggles): additionally watches the tags of +/// verified local mesh peers and hands matching drops to them as directed +/// courier packets, so mesh-only recipients are served too. +/// +/// Privacy: a drop reveals to relays only that "someone" is messaging "some +/// 16-byte day-rotating tag". Only parties who already know the recipient's +/// Noise static key can compute the tag; the payload is an opaque Noise-X +/// seal. Duplicate deliveries (drop + physical courier + direct link) are +/// absorbed downstream by message-ID dedup. +@MainActor +final class BridgeCourierService: ObservableObject { + enum Limits { + /// Drops waiting for relay connectivity (bounded, drop-oldest). + static let maxPendingDrops = 20 + /// Republish cooldown for gateway-held envelopes. + static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60 + /// Local peers a gateway watches drops for (x3 candidate tags each). + static let maxWatchedPeers = 16 + /// Tag-set refresh cadence (also covers UTC day rollover). + static let refreshIntervalSeconds: TimeInterval = 30 * 60 + /// Minimum spacing for announce-driven refreshes. + static let announceRefreshDebounceSeconds: TimeInterval = 60 + /// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack). + static let maxDropEnvelopeBytes = 20 * 1024 + static let maxTrackedIDs = 512 + } + + static let shared = BridgeCourierService() + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + var bridgeEnabled: (@MainActor () -> Bool)? + var relaysConnected: (@MainActor () -> Bool)? + /// Publishes a signed drop event to the default (DM) relays. + var publishEvent: (@MainActor (NostrEvent) -> Void)? + /// (Re)opens the drop subscription for the given hex tags. + var openSubscription: (@MainActor ([String]) -> Void)? + var closeSubscription: (@MainActor () -> Void)? + /// Our own Noise static public key. + var myNoiseKey: (@MainActor () -> Data?)? + /// Verified reachable local peers with known Noise keys. + var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])? + /// Seals content into a carry-only envelope for a recipient key. + var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)? + /// Opens a drop addressed to us (tag verified inside). + var openEnvelope: (@MainActor (CourierEnvelope) -> Void)? + /// Hands a drop to a matching local peer as a directed courier packet. + var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)? + /// Held envelopes eligible for (re)publish, honoring the cooldown. + var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])? + /// Timer injection for tests; nil arms a real `Task`. + var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + private(set) var myTagsHex: Set = [] + private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set)] = [] + private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = [] + /// Message IDs already published as drops (sender-side dedup). + private var publishedDropKeys: BoundedIDSet + /// Drop event IDs already handled (multi-relay dedup). + private var seenDropEventIDs: BoundedIDSet + private var subscriptionOpen = false + private var lastSubscribedTags: Set = [] + private var refreshTimerArmed = false + private var lastAnnounceRefresh = Date.distantPast + + private let now: () -> Date + + init(now: @escaping () -> Date = Date.init) { + self.now = now + self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs) + self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs) + } + + // MARK: - Sender role + + /// Parallel-deposit a sealed copy of an outbound private message as a + /// relay drop. Called by the message router alongside physical courier + /// deposits; idempotent per message ID. Returns true when a fresh drop + /// was sealed (published now or queued for the next relay connection) — + /// the router marks the message "carried" so the sender sees progress. + @discardableResult + func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool { + guard bridgeEnabled?() ?? false else { return false } + guard !publishedDropKeys.contains(messageID) else { return false } + guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false } + publishedDropKeys.insert(messageID) + publishDrop(envelope) + return true + } + + /// Publishes held envelopes (mail we carry for others) as drops, + /// honoring the per-envelope cooldown. + func publishHeldEnvelopes() { + guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return } + for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] { + publishDrop(envelope) + } + } + + private func publishDrop(_ envelope: CourierEnvelope) { + guard let encoded = envelope.encode(), + encoded.count <= Limits.maxDropEnvelopeBytes, + !envelope.isExpired else { return } + guard relaysConnected?() ?? false else { + pendingDrops.append((envelope, nil)) + if pendingDrops.count > Limits.maxPendingDrops { + pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops) + } + return + } + guard let identity = Self.makeThrowawayIdentity(), + let event = try? NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: envelope.recipientTag.hexEncodedString(), + expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), + senderIdentity: identity + ) else { + SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) + return + } + publishEvent?(event) + SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session) + } + + /// Drops queued while relays were unreachable publish on reconnect. + func flushPendingDrops() { + guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return } + let queued = pendingDrops + pendingDrops.removeAll() + for item in queued { + publishDrop(item.envelope) + } + } + + // MARK: - Subscription (recipient + gateway watch) + + /// Recomputes the watched tag set and (re)opens the subscription. + /// Call on toggle changes, relay connectivity changes, and periodically + /// (tags rotate daily); idempotent. + func refresh() { + armRefreshTimerIfNeeded() + guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { + if subscriptionOpen { + closeSubscription?() + subscriptionOpen = false + } + return + } + let date = now() + if let myKey = myNoiseKey?() { + myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() }) + } else { + myTagsHex = [] + } + // While bridging with internet, every device watches drops for its + // verified local peers — the single-switch analogue of gateway duty. + let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers) + watchedPeerTags = peers.map { peer in + (peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() })) + } + let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex)) + guard !allTags.isEmpty else { + if subscriptionOpen { + closeSubscription?() + subscriptionOpen = false + lastSubscribedTags = [] + } + return + } + // Resubscribe only when the watched set actually changed — refresh + // fires on every verified announce (field logs showed the drop + // subscription rebuilt every ~60s for an unchanged tag set). + if !subscriptionOpen || allTags != lastSubscribedTags { + openSubscription?(allTags.sorted()) + subscriptionOpen = true + lastSubscribedTags = allTags + } + flushPendingDrops() + publishHeldEnvelopes() + } + + /// Announce-driven refresh, debounced — a newly verified peer should be + /// watched promptly, but announce storms must not thrash subscriptions. + func refreshAfterVerifiedAnnounce() { + guard bridgeEnabled?() ?? false else { return } + guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return } + lastAnnounceRefresh = now() + refresh() + } + + private func armRefreshTimerIfNeeded() { + guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return } + refreshTimerArmed = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.refreshTimerArmed = false + self.refresh() + } + if let scheduleTimer { + scheduleTimer(Limits.refreshIntervalSeconds, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Inbound drops + + /// Entry point for every drop event the subscription delivers (the relay + /// manager has already verified the event signature). + func handleDropEvent(_ event: NostrEvent) { + guard bridgeEnabled?() ?? false else { return } + guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return } + guard seenDropEventIDs.insert(event.id) else { return } + guard let data = Data(base64Encoded: event.content), + data.count <= Limits.maxDropEnvelopeBytes, + let envelope = CourierEnvelope.decode(data), + !envelope.isExpired else { + return + } + let tagHex = envelope.recipientTag.hexEncodedString() + // The envelope's own tag must match the event's filterable tag — + // otherwise a mislabeled drop could ride a subscription it doesn't + // belong to. + guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return } + + if myTagsHex.contains(tagHex) { + SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session) + openEnvelope?(envelope) + return + } + if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) { + SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session) + deliverToPeer?(envelope, match.peerID) + } + } + + // MARK: - Helpers + + /// A fresh random Nostr identity for signing one drop. + static func makeThrowawayIdentity() -> NostrIdentity? { + for _ in 0..<10 { + var bytes = Data(count: 32) + let status = bytes.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } + guard status == errSecSuccess else { return nil } + if let identity = try? NostrIdentity(privateKeyData: bytes) { + return identity + } + } + return nil + } +} diff --git a/bitchat/Services/Gateway/BridgeService.swift b/bitchat/Services/Gateway/BridgeService.swift new file mode 100644 index 00000000..eb84944d --- /dev/null +++ b/bitchat/Services/Gateway/BridgeService.swift @@ -0,0 +1,689 @@ +// +// BridgeService.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Policy engine for the mesh bridge: an opt-in stitcher of disjoint BLE +/// mesh islands that share a place. While the toggle is on, this device's +/// public mesh messages are additionally signed (with a derived, unlinkable +/// per-cell Nostr identity) as rendezvous events for the local geohash cell +/// and published to the cell's deterministic geo relays — directly when we +/// have internet, or deposited with a bridge gateway peer over a directed +/// `toBridge` carrier when we are mesh-only. Inbound rendezvous events from +/// other islands render into the mesh timeline marked as bridged. +/// +/// A device with BOTH this toggle and the gateway toggle on serves the +/// island: it accepts `toBridge` deposits, publishes them, and rebroadcasts +/// remote rendezvous events onto the mesh as `fromBridge` carriers so +/// mesh-only peers see across the bridge too. +/// +/// Consent model: +/// - Nothing crosses a bridge unless its author signed it for the bridge: +/// gateways carry only finished, Schnorr-signed rendezvous events, so a +/// neighbor's gateway cannot exfiltrate radio-only traffic. The per-message +/// "nearby only" flag simply skips composing a rendezvous copy. +/// - Receiving over radio (`fromBridge` carriers) is always on — it is +/// passive and leaks nothing. Subscribing over the internet (which reveals +/// the coarse cell to relays) and publishing both require the toggle. +/// +/// Loop-prevention rules (adapted from `GatewayService`, unit-tested): +/// 1. An event learned from a `fromBridge` mesh broadcast is never published +/// and never rebroadcast (`meshBroadcastEventIDs`) — a second bridge +/// gateway on the same island cannot echo mesh-carried traffic. +/// 2. An event this device published (own messages or uplinked deposits, +/// `publishedEventIDs`) is never downlink-rebroadcast: it originated on +/// this island, so our own relay subscription redelivering it must not +/// double BLE airtime. +/// 3. A subscription event is rebroadcast at most once +/// (`rebroadcastEventIDs`, marked after send), and never when the island +/// already holds the radio copy (`isMessageSeenLocally` on the event's +/// mesh message ID) — remote islands' traffic is the only thing worth +/// airtime. +/// Receivers additionally dedup by timeline message ID: a bridged copy +/// carries the original mesh message ID in its `m` tag, so the store's +/// insert-by-ID absorbs radio/bridge duplicates in either arrival order. +/// +/// All dependencies are closure-injected (repo convention) so the policy +/// layer is unit-testable without relays, radios, or CoreLocation. +@MainActor +final class BridgeService: ObservableObject { + enum Limits { + /// Uplink deposits held while relays are unreachable. + static let maxQueuedUplinks = 20 + static let maxQueuedUplinksPerDepositor = 5 + /// Uplink deposits accepted per depositor per minute. + static let uplinkEventsPerMinutePerDepositor = 10 + /// Downlink mesh rebroadcasts per minute — BLE airtime is precious, + /// and bridge traffic shares the radio with everything else. + static let downlinkEventsPerMinute = 20 + static let maxPendingDownlinks = 30 + /// Accepted clock skew for a rendezvous event. + static let maxEventAgeSeconds: TimeInterval = 15 * 60 + /// Bounded loop-prevention ID caches (oldest evicted). + static let maxTrackedEventIDs = 512 + /// Presence heartbeat cadence while the bridge is active. + static let presenceIntervalSeconds: TimeInterval = 4 * 60 + /// A rendezvous participant counts toward "via bridge" for this long + /// after their last event. + static let participantFreshnessSeconds: TimeInterval = 10 * 60 + /// Content cap, matching the public-message pipeline's own limit. + static let maxContentBytes = 16_000 + /// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km). + static let cellPrecision = 6 + } + + struct QueuedUplink { + let depositor: PeerID + let cell: String + let event: NostrEvent + } + + /// A validated rendezvous message ready for the timeline. + struct InboundBridgeMessage { + let messageID: String + let senderNickname: String + let senderPubkey: String + let content: String + let timestamp: Date + } + + /// A person currently visible across the bridge (fresh, not attributed + /// to the local island), for the people sheet. + struct BridgedParticipant: Identifiable, Equatable { + let pubkey: String + let nickname: String? + let lastSeen: Date + var id: String { pubkey } + /// Geohash-chat convention: nickname#last-4-of-pubkey, so two remote + /// "anon"s stay distinguishable. + var displayName: String { + (nickname?.trimmedOrNilIfEmpty ?? "anon") + "#" + String(pubkey.suffix(4)) + } + } + + static let shared = BridgeService() + + /// The user toggle. While true this device publishes its own public mesh + /// messages to the rendezvous and subscribes to it when online. + @Published private(set) var isEnabled: Bool + /// Distinct remote rendezvous participants seen within the freshness + /// window. Approximate by design: local participants are subtracted by + /// matching their events' mesh message IDs against the local timeline, + /// which cannot attribute silent (presence-only) local peers. + @Published private(set) var bridgedPeerCount: Int = 0 + /// The people behind the count, newest activity first. + @Published private(set) var bridgedParticipants: [BridgedParticipant] = [] + /// The rendezvous cell currently in use, when the bridge is active. + @Published private(set) var activeCell: String? + /// Per-session compose flag: while true, outgoing messages stay on the + /// radio — no rendezvous copy is composed, so no gateway can carry them. + @Published var nearbyOnly: Bool = false + + // MARK: Wiring (set once by the bootstrapper; fakes in tests) + + /// Publishes a signed event to the geo relays for a cell. + var publishToRelays: (@MainActor (NostrEvent, String) -> Void)? + /// Opens the rendezvous subscription for (cell + neighbors); events are + /// fed back via `handleRendezvousEvent`. + var openSubscription: (@MainActor ([String]) -> Void)? + /// Closes the rendezvous subscription. + var closeSubscription: (@MainActor () -> Void)? + /// Whether any Nostr relay connection is currently working. + var relaysConnected: (@MainActor () -> Bool)? + /// The local neighborhood cell from CoreLocation, if permitted. + var locationCell: (@MainActor () -> String?)? + /// Asks the location layer for a fresh one-shot fix. The bridge must + /// pump location itself: channel data otherwise only flows while some + /// other feature (channels sheet, location notes) happens to be active — + /// a field failure mode where the bridge silently never got a cell. + var requestLocationFix: (@MainActor () -> Void)? + /// A rendezvous cell advertised by a reachable mesh bridge peer's + /// announce — lets a mesh-only, location-less device still compose + /// correctly tagged events. + var meshAdvertisedCell: (@MainActor () -> String?)? + /// Sends an encoded `toBridge` carrier directed to a bridge peer. + var sendToBridgePeer: (@MainActor (Data, PeerID) -> Bool)? + /// Reachable mesh peers advertising the `.bridge` capability. + var availableBridgePeers: (@MainActor () -> [PeerID])? + /// Broadcasts an encoded `fromBridge` carrier on the mesh. + var broadcastToMesh: (@MainActor (Data) -> Void)? + /// Delivers a validated inbound bridge message to the mesh timeline. + var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)? + /// True when the mesh timeline already holds this message ID (the radio + /// copy) — used to skip pointless downlink airtime. + var isMessageSeenLocally: (@MainActor (String) -> Bool)? + /// Derives the unlinkable per-cell rendezvous identity. + var deriveIdentity: (@MainActor (String) throws -> NostrIdentity)? + /// Local nickname for the `n` tag. + var myNickname: (@MainActor () -> String)? + /// Fired on toggle changes (advertise/withdraw `.bridge` + re-announce). + var onEnabledChanged: (@MainActor (Bool) -> Void)? + /// Fired when the active rendezvous cell changes (including to nil) so + /// the announce advertisement stays current. + var onActiveCellChanged: (@MainActor (String?) -> Void)? + /// Schedules a closure after a delay; nil arms a real `Task`. Injected so + /// timers are deterministic in tests. + var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)? + + // MARK: State + + /// Loop rule 1: event IDs seen in `fromBridge` mesh broadcasts. + private var meshBroadcastEventIDs: BoundedIDSet + /// Loop rule 2: event IDs this device published (own or deposited). + private var publishedEventIDs: BoundedIDSet + /// Loop rule 3: event IDs this device already rebroadcast. + private var rebroadcastEventIDs: BoundedIDSet + /// Timeline message IDs already injected (either arrival path). + private var injectedMessageIDs: BoundedIDSet + + /// Cells the rendezvous subscription covers (own + neighbor ring). + private(set) var subscribedCells: Set = [] + private(set) var queuedUplinks: [QueuedUplink] = [] + private var uplinkDepositTimes: [PeerID: [Date]] = [:] + private var downlinkSendTimes: [Date] = [] + private var pendingDownlinks: [(event: NostrEvent, cell: String)] = [] + private var downlinkDrainScheduled = false + private var presenceTimerArmed = false + private var lastPresenceAt = Date.distantPast + + /// pubkey -> (lastSeen, attributed-to-local-island, last known nickname). + private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:] + + private let defaults: UserDefaults + private let now: () -> Date + private static let enabledKey = "bridge.userEnabled" + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) { + self.defaults = defaults + self.now = now + self.isEnabled = defaults.bool(forKey: Self.enabledKey) + self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs) + } + + // MARK: - Toggle & lifecycle + + func setEnabled(_ enabled: Bool) { + guard enabled != isEnabled else { return } + isEnabled = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { + queuedUplinks.removeAll() + pendingDownlinks.removeAll() + uplinkDepositTimes.removeAll() + participants.removeAll() + bridgedPeerCount = 0 + bridgedParticipants = [] + } + SecureLogger.info("🌉 Bridge mode \(enabled ? "enabled" : "disabled")", category: .session) + refreshRendezvous() + onEnabledChanged?(enabled) + } + + /// Recomputes the active cell and (re)opens or closes the subscription. + /// Call on toggle changes, location updates, and relay connectivity + /// changes; idempotent. + func refreshRendezvous() { + let cell = isEnabled ? currentCell() : nil + // No cell yet: ask for a fix — the availableChannels change re-enters + // here once it lands. + if isEnabled, cell == nil { + requestLocationFix?() + } + guard cell != activeCell else { + // The maintenance timer must run even cell-less: it is what + // retries the location fix (launch races the permission + // callback, so the first request can silently no-op). + if isEnabled { armPresenceTimerIfNeeded() } + return + } + if activeCell != nil { + closeSubscription?() + subscribedCells = [] + } + activeCell = cell + onActiveCellChanged?(cell) + guard let cell else { + if isEnabled { armPresenceTimerIfNeeded() } + return + } + // Own cell + neighbors: islands straddling a cell edge still meet. + // Publishes go to the own cell only; symmetric because both sides + // subscribe to each other's cell via the neighbor ring. + let cells = [cell] + Geohash.neighbors(of: cell) + subscribedCells = Set(cells) + openSubscription?(cells) + SecureLogger.info("🌉 Bridge: rendezvous open for cell \(cell)", category: .session) + publishPresence() + armPresenceTimerIfNeeded() + } + + /// The rendezvous cell: our own location when we have it, else the cell + /// a reachable bridge gateway advertises in its announce. + private func currentCell() -> String? { + if let own = locationCell?(), !own.isEmpty { + return String(own.prefix(Limits.cellPrecision)) + } + if let advertised = meshAdvertisedCell?(), GatewayService.isValidGeohash(advertised) { + return String(advertised.prefix(Limits.cellPrecision)) + } + return nil + } + + // One switch does the right thing: while bridging, a device with + // internet automatically serves its island (accepts deposits, carries + // remote messages onto the radio). The marginal cost over bridging + // yourself is small — the relay connections and subscription already + // exist for you — and a separate "serve others" lever proved to be a + // silent trap for mesh-only neighbors. + + // MARK: - Outgoing (sender role) + + /// Composes and ships the bridged copy of an outgoing public mesh + /// message. Call after the radio send; no-op when the bridge is off, + /// no cell is known, or the message was flagged nearby-only upstream. + func bridgeOutgoing(content: String, messageID: String) { + guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return } + guard content.utf8.count <= Limits.maxContentBytes else { return } + guard let identity = try? deriveIdentity?(cell), + let event = try? NostrProtocol.createBridgeMeshEvent( + content: content, + cell: cell, + senderIdentity: identity, + nickname: myNickname?(), + meshMessageID: messageID + ) else { + SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session) + return + } + publishedEventIDs.insert(event.id) + injectedMessageIDs.insert(messageID) // our own timeline already has it + if relaysConnected?() ?? false { + publishToRelays?(event, cell) + } else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event), + let payload = carrier.encode(), + let gateway = availableBridgePeers?().first { + if sendToBridgePeer?(payload, gateway) ?? false { + SecureLogger.debug("🌉 Bridge: uplinked own event via gateway \(gateway.id.prefix(8))…", category: .session) + } + } + } + + /// Publishes a presence heartbeat so silent participants still register + /// across the bridge. Throttled: several triggers (enable, cell change, + /// relay reconnect) can coincide, and same-second heartbeats are + /// byte-identical events anyway. + func publishPresence() { + guard isEnabled, let cell = activeCell, relaysConnected?() ?? false else { return } + guard now().timeIntervalSince(lastPresenceAt) >= 30 else { return } + lastPresenceAt = now() + guard let identity = try? deriveIdentity?(cell), + let event = try? NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: identity) else { return } + publishedEventIDs.insert(event.id) + publishToRelays?(event, cell) + } + + /// Maintenance heartbeat while bridging: presence, participant pruning, + /// and a location retry. Runs with or without a cell — the cell-less + /// case is exactly when the location retry matters. + private func armPresenceTimerIfNeeded() { + guard isEnabled, !presenceTimerArmed else { return } + presenceTimerArmed = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.presenceTimerArmed = false + self.publishPresence() + self.pruneParticipants() + // Location refresh: migrates cells on a moving device and + // recovers a launch that raced the permission callback. + if self.activeCell == nil { + self.refreshRendezvous() + } else { + self.requestLocationFix?() + } + self.armPresenceTimerIfNeeded() + } + if let scheduleTimer { + scheduleTimer(Limits.presenceIntervalSeconds, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(Limits.presenceIntervalSeconds * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Subscription ingress (internet role) + + /// Entry point for every event the rendezvous subscription delivers. + /// Handles presence accounting, timeline injection, and — when acting as + /// the island's gateway — downlink rebroadcast. + func handleRendezvousEvent(_ event: NostrEvent) { + guard isEnabled else { return } + // The subscription spans our cell + neighbors; trust only the + // event's own signed `r` tag, and only within that ring. + guard let cell = event.tags.first(where: { $0.count >= 2 && $0[0] == "r" })?[1], + subscribedCells.contains(cell) else { + return + } + guard let kind = classify(event, cell: cell) else { return } + // Events we published come back from our own subscription; they are + // presence-neutral (we never count ourselves) and never re-injected + // or rebroadcast. Two layers: the published-ID cache (this session) + // and pubkey self-recognition — the rendezvous identity is derived + // deterministically, so even after a relaunch wipes the cache our + // own relay-backfilled events are recognized (field bug: own + // pre-restart messages re-rendered as bridged). + guard !publishedEventIDs.contains(event.id) else { return } + if isOwnRendezvousEvent(event, cell: cell) { + publishedEventIDs.insert(event.id) // never downlink it either + return + } + guard event.isValidSignature() else { return } + + switch kind { + case .presence: + recordParticipant(event.pubkey, isLocal: false, nickname: nil) + case .message(let message): + let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false + if isLocalRadioCopy { + SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session) + } + recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname) + inject(message) + // Serving duty: carry remote islands' messages onto the radio for + // mesh-only peers. Local-origin events are skipped — the island + // already heard them (loop rule 3). The drain is jitter-delayed: + // with every online bridger serving, the holdoff lets gateways + // hear each other's broadcasts and skip duplicates. + if !isLocalRadioCopy, + !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id), + !pendingDownlinks.contains(where: { $0.event.id == event.id }) { + pendingDownlinks.append((event, cell)) + if pendingDownlinks.count > Limits.maxPendingDownlinks { + pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks) + } + scheduleDownlinkDrainIfNeeded(jitter: true) + } + } + } + + // MARK: - Mesh carrier ingress (both roles) + + /// Entry point for received `nostrCarrier` packets with bridge + /// directions. `directedToUs` is true for `toBridge` deposits addressed + /// to this device; false for `fromBridge` broadcasts. + func handleMeshCarrier(_ carrier: NostrCarrierPacket, from peerID: PeerID, directedToUs: Bool) { + switch carrier.direction { + case .toBridge: + guard directedToUs else { return } + handleUplinkDeposit(carrier, from: peerID) + case .fromBridge: + guard !directedToUs else { return } + handleDownlinkBroadcast(carrier) + case .toGateway, .fromGateway: + return // GatewayService territory; routed there by the caller. + } + } + + // MARK: - Uplink (gateway role: mesh peer -> internet) + + private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) { + guard isEnabled else { return } + // Cheap structural gates before any crypto, mirroring GatewayService. + guard let event = structurallyValidEvent(from: carrier) else { + SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (failed validation)", category: .security) + return + } + guard !meshBroadcastEventIDs.contains(event.id), + !publishedEventIDs.contains(event.id), + !queuedUplinks.contains(where: { $0.event.id == event.id }) else { + return + } + guard allowUplinkDeposit(from: depositor) else { + SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))…", category: .session) + return + } + guard event.isValidSignature() else { + SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security) + return + } + if relaysConnected?() ?? false { + publish(event, cell: carrier.geohash) + } else { + enqueueUplink(QueuedUplink(depositor: depositor, cell: carrier.geohash, event: event)) + } + // No local injection: the depositor's radio broadcast already carried + // the message to this island, including us. + } + + /// Publish everything queued while relays were unreachable. + func flushQueuedUplinks() { + guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return } + let queued = queuedUplinks + queuedUplinks.removeAll() + for item in queued where !publishedEventIDs.contains(item.event.id) { + publish(item.event, cell: item.cell) + } + } + + private func publish(_ event: NostrEvent, cell: String) { + publishedEventIDs.insert(event.id) + publishToRelays?(event, cell) + SecureLogger.info("🌉 Bridge: published carried event \(event.id.prefix(8))… for cell \(cell)", category: .session) + } + + @discardableResult + private func enqueueUplink(_ item: QueuedUplink) -> Bool { + let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count + guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else { return false } + if queuedUplinks.count >= Limits.maxQueuedUplinks { + queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1) + } + queuedUplinks.append(item) + return true + } + + private func allowUplinkDeposit(from depositor: PeerID) -> Bool { + let cutoff = now().addingTimeInterval(-60) + var times = uplinkDepositTimes[depositor, default: []] + times.removeAll { $0 < cutoff } + guard times.count < Limits.uplinkEventsPerMinutePerDepositor else { + uplinkDepositTimes[depositor] = times + return false + } + times.append(now()) + uplinkDepositTimes[depositor] = times + if uplinkDepositTimes.count > Limits.maxTrackedEventIDs { + uplinkDepositTimes = uplinkDepositTimes.filter { $0.value.contains { $0 >= cutoff } } + } + return true + } + + // MARK: - Downlink (gateway role: internet -> mesh) + + private func drainPendingDownlinks() { + let cutoff = now().addingTimeInterval(-60) + downlinkSendTimes.removeAll { $0 < cutoff } + while !pendingDownlinks.isEmpty, + downlinkSendTimes.count < Limits.downlinkEventsPerMinute { + let (event, cell) = pendingDownlinks.removeFirst() + guard isFresh(event) else { continue } + // Suppression recheck at send time: another gateway may have + // broadcast this event during our jitter holdoff. + guard !meshBroadcastEventIDs.contains(event.id), + !rebroadcastEventIDs.contains(event.id) else { continue } + guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event), + let payload = carrier.encode() else { continue } + broadcastToMesh?(payload) + SecureLogger.debug("🌉 Bridge: downlinked remote event \(event.id.prefix(8))… onto the mesh", category: .session) + // Mark-after-send (loop rule 3): a queue-overflow drop stays + // retryable on relay redelivery. + rebroadcastEventIDs.insert(event.id) + downlinkSendTimes.append(now()) + } + scheduleDownlinkDrainIfNeeded() + } + + private func scheduleDownlinkDrainIfNeeded(jitter: Bool = false) { + guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return } + let delay: TimeInterval + if jitter { + // Multi-gateway suppression window: enough spread for another + // gateway's broadcast to land and mark the event mesh-carried. + delay = Double.random(in: 0.2...1.5) + } else { + let oldest = downlinkSendTimes.min() ?? now() + delay = max(0.05, 60 - now().timeIntervalSince(oldest)) + } + downlinkDrainScheduled = true + let fire: @MainActor () -> Void = { [weak self] in + guard let self else { return } + self.downlinkDrainScheduled = false + self.drainPendingDownlinks() + } + if let scheduleTimer { + scheduleTimer(delay, fire) + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + fire() + } + } + } + + // MARK: - Downlink (receiver role: carried event arrives over radio) + + private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) { + // Reception is deliberately NOT gated on the toggle: it is passive + // radio, and two phones side by side should not disagree about what + // the channel said. Publishing/subscribing remain opt-in. + guard let event = structurallyValidEvent(from: carrier), + !publishedEventIDs.contains(event.id), + !isOwnRendezvousEvent(event, cell: carrier.geohash), + event.isValidSignature() else { + return + } + // Mark after verification (a forged copy must not poison the cache), + // and use the marking as multi-path dedup. + guard meshBroadcastEventIDs.insert(event.id) else { return } + guard case .message(let message)? = classify(event, cell: carrier.geohash) else { + return + } + recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname) + inject(message) + } + + // MARK: - Injection & participants + + private func inject(_ message: InboundBridgeMessage) { + guard injectedMessageIDs.insert(message.messageID) else { return } + guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return } + SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session) + injectInbound?(message) + } + + private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) { + let previous = participants[pubkey] + // Local attribution is sticky: one radio-confirmed message marks the + // pubkey as an islander for as long as they stay fresh. Presence + // events carry no nickname, so a known name is never forgotten. + participants[pubkey] = ( + lastSeen: now(), + isLocal: (previous?.isLocal ?? false) || isLocal, + nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname + ) + recomputeBridgedCount() + } + + private func pruneParticipants() { + let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) + participants = participants.filter { $0.value.lastSeen >= cutoff } + recomputeBridgedCount() + } + + private func recomputeBridgedCount() { + let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds) + let visible = participants + .filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal } + .map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) } + .sorted { $0.lastSeen > $1.lastSeen } + if visible.count != bridgedPeerCount { + bridgedPeerCount = visible.count + } + if visible != bridgedParticipants { + bridgedParticipants = visible + } + } + + // MARK: - Validation + + private enum RendezvousKind { + case message(InboundBridgeMessage) + case presence + } + + /// Classifies a structurally acceptable rendezvous event; nil rejects. + private func classify(_ event: NostrEvent, cell: String) -> RendezvousKind? { + guard isFresh(event), + event.tags.contains(where: { $0.count >= 2 && $0[0] == "r" && $0[1] == cell }), + GatewayService.isValidGeohash(cell) else { + return nil + } + switch event.kind { + case NostrProtocol.EventKind.geohashPresence.rawValue: + return .presence + case NostrProtocol.EventKind.ephemeralEvent.rawValue: + let content = event.content + guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil } + let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1] + let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1] + let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id + return .message(InboundBridgeMessage( + messageID: messageID, + senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.prefix(4))", + senderPubkey: event.pubkey, + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) + )) + default: + return nil + } + } + + /// Parse + size + cell + kind + `r` tag + freshness, with NO signature + /// verification — callers dedup/rate-limit first, Schnorr-verify last. + private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? { + guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes, + GatewayService.isValidGeohash(carrier.geohash), + let event = carrier.event(), + classify(event, cell: carrier.geohash) != nil else { + return nil + } + return event + } + + private func isFresh(_ event: NostrEvent) -> Bool { + abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds + } + + /// True when the event was signed by this device's own derived + /// rendezvous identity for the cell. Survives relaunches (unlike the + /// published-ID cache) because the derivation is deterministic; the + /// underlying identity cache makes this cheap. + private func isOwnRendezvousEvent(_ event: NostrEvent, cell: String) -> Bool { + guard let identity = try? deriveIdentity?(cell) else { return false } + return identity.publicKeyHex.lowercased() == event.pubkey.lowercased() + } +} diff --git a/bitchat/Services/Gateway/GatewayService.swift b/bitchat/Services/Gateway/GatewayService.swift index 3b5ec30a..0c7bdc9f 100644 --- a/bitchat/Services/Gateway/GatewayService.swift +++ b/bitchat/Services/Gateway/GatewayService.swift @@ -178,6 +178,10 @@ final class GatewayService: ObservableObject { // Downlink rides broadcast only; a directed fromGateway is malformed. guard !directedToUs else { return } handleDownlinkBroadcast(carrier) + case .toBridge, .fromBridge: + // Mesh-bridge carriers are BridgeService territory; the ingress + // router dispatches them there. + return } } @@ -462,30 +466,3 @@ final class GatewayService: ObservableObject { && geohash.allSatisfy { allowed.contains($0) } } } - -/// Insertion-ordered string set with a fixed capacity; the oldest entry is -/// evicted when full. -private struct BoundedIDSet { - private var members: Set = [] - private var order: [String] = [] - let capacity: Int - - init(capacity: Int) { - self.capacity = capacity - } - - func contains(_ id: String) -> Bool { - members.contains(id) - } - - /// Returns false when the ID was already present. - @discardableResult - mutating func insert(_ id: String) -> Bool { - guard members.insert(id).inserted else { return false } - order.append(id) - if order.count > capacity { - members.remove(order.removeFirst()) - } - return true - } -} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 54b53ee8..f8474776 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -52,6 +52,53 @@ final class MessageRouter { /// message until an ack arrives. var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? + /// Parallel deposit into the internet bridge: park a sealed copy on + /// relays as a courier drop, so delivery stops requiring a physical + /// courier encounter. No-op unless the bridge is enabled. Runs alongside + /// (not instead of) mesh couriers; receivers dedup by message ID. + /// Returns true when a fresh drop was sealed, so the sender's message + /// can show the "carried" state instead of sitting on "sending" forever + /// (the delivery ack has no radio route back until the peers next share + /// a transport). + var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)? + + /// Re-attempts bridge drops for retained messages whose recipient no + /// transport can promptly reach anymore. Covers sends that raced the BLE + /// reachability retention window: a peer stays "reachable" for a minute + /// after its radio disappears, so the original send trusted the mesh and + /// skipped the deposit — and nothing else ever retried (field-found). + /// Safe to call often: the drop layer dedups by message ID. + func retryBridgeCourierDeposits() { + guard bridgeCourierDeposit != nil else { return } + for (peerID, queue) in outbox { + guard let recipientKey = courierDirectory.noiseKey(peerID) else { continue } + let promptlyDeliverable = transports.contains { + $0.isPeerReachable(peerID) && $0.canDeliverPromptly(to: peerID) + } + guard !promptlyDeliverable else { continue } + for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds { + if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true { + onMessageCarried?(message.messageID, peerID) + } + } + } + } + + /// Arms the periodic sweep behind `retryBridgeCourierDeposits`. Called + /// once by the bootstrapper after the deposit closure is wired; separate + /// from init so tests drive the retry directly. + func startBridgeDepositSweep(interval: TimeInterval = 120) { + bridgeSweepTask?.cancel() + bridgeSweepTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + self?.retryBridgeCourierDeposits() + } + } + } + + private var bridgeSweepTask: Task? + private var outbox: [PeerID: [QueuedMessage]] = [:] // Outbox limits to prevent unbounded memory growth @@ -160,6 +207,11 @@ final class MessageRouter { private func attemptCourierDeposit(messageID: String, for peerID: PeerID) { guard let recipientKey = courierDirectory.noiseKey(peerID), let entry = queuedMessage(messageID, for: peerID) else { return } + // The bridge drop needs no connected courier — only the recipient + // key — so it runs before the courier-slot bookkeeping. + if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true { + onMessageCarried?(messageID, peerID) + } let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count guard remainingSlots > 0 else { return } diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index d8005099..d0638ebd 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -42,6 +42,9 @@ protocol ChatOutgoingContext: AnyObject { func recordPublicActivity(forChannelKey key: String) func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendGeohash(context: ChatViewModel.GeoOutgoingContext) + /// Ships the bridged (rendezvous) copy of a just-sent public mesh + /// message; no-op when the bridge is off or the send is nearby-only. + func bridgeOutgoingPublicMessage(_ content: String, messageID: String) // MARK: Geohash identity (shared with the other contexts) func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity @@ -62,6 +65,10 @@ extension ChatViewModel: ChatOutgoingContext { func recordPublicActivity(forChannelKey key: String) { lastPublicActivityAt[key] = Date() } + + func bridgeOutgoingPublicMessage(_ content: String, messageID: String) { + BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID) + } } @MainActor @@ -140,6 +147,7 @@ private extension ChatOutgoingCoordinator { messageID: message.id, timestamp: message.timestamp ) + context.bridgeOutgoingPublicMessage(trimmed, messageID: message.id) } /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 55a8925a..4d761d8f 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -77,6 +77,8 @@ final class ChatViewModelBootstrapper { bindTransferProgress() configureGeoChannels() configureGateway() + configureBridge() + configureBridgeCourier() bindTeleportState() requestNotifications() registerObservers() @@ -350,15 +352,31 @@ private extension ChatViewModelBootstrapper { bleService?.setLocalCapability(.gateway, enabled: enabled) } bleService.onNostrCarrierPacket = { payload, from, directedToUs in - GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + // One decode, two policy engines: geohash-channel carriers go to + // the gateway, mesh-bridge carriers to the bridge. + guard let carrier = NostrCarrierPacket.decode(payload) else { + SecureLogger.debug("🌐 Gateway: dropping undecodable carrier from \(from.id.prefix(8))…", category: .session) + return + } + switch carrier.direction { + case .toGateway, .fromGateway: + GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs) + case .toBridge, .fromBridge: + BridgeService.shared.handleMeshCarrier(carrier, from: from, directedToUs: directedToUs) + } } // Uplinks deposited while relays were unreachable flush on reconnect. + // The publisher re-emits `true` on every relay state recompute, so + // dedupe: field logs showed presence published 5x in one second. NostrRelayManager.shared.$isConnected + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { connected in if connected { GatewayService.shared.flushQueuedUplinks() + BridgeService.shared.flushQueuedUplinks() + BridgeService.shared.publishPresence() } } .store(in: &viewModel.cancellables) @@ -369,6 +387,217 @@ private extension ChatViewModelBootstrapper { } } + /// Wires the mesh-bridge policy layer (`BridgeService`) to the mesh + /// transport, the relay manager, location, and the public timeline. Same + /// closure-injection style as `configureGateway`. + func configureBridge() { + guard let bleService = viewModel.meshService as? BLEService else { return } + let bridge = BridgeService.shared + let idBridge = viewModel.idBridge + + bridge.publishToRelays = { event, cell in + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: cell, + count: TransportConfig.nostrGeoRelayCount + ) + guard !relays.isEmpty else { + SecureLogger.warning("🌉 Bridge: no geo relays for cell \(cell); not publishing", category: .session) + return + } + NostrRelayManager.shared.sendEvent(event, to: relays) + } + bridge.openSubscription = { cells in + guard let cell = cells.first else { return } + let relays = GeoRelayDirectory.shared.closestRelays( + toGeohash: cell, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe( + filter: .bridgeRendezvous(cells, since: Date().addingTimeInterval(-BridgeService.Limits.maxEventAgeSeconds)), + id: Self.bridgeSubscriptionID, + relayUrls: relays.isEmpty ? nil : relays, + handler: { event in + BridgeService.shared.handleRendezvousEvent(event) + } + ) + } + bridge.closeSubscription = { + NostrRelayManager.shared.unsubscribe(id: Self.bridgeSubscriptionID) + } + bridge.relaysConnected = { NostrRelayManager.shared.isConnected } + bridge.locationCell = { [weak viewModel] in + viewModel?.locationManager.availableChannels + .first { $0.level == .neighborhood }? + .geohash + } + bridge.requestLocationFix = { [weak viewModel] in + viewModel?.locationManager.refreshChannels() + } + bridge.meshAdvertisedCell = { [weak bleService] in + bleService?.advertisedBridgeGeohash() + } + bridge.sendToBridgePeer = { [weak bleService] payload, peer in + bleService?.sendNostrCarrier(payload, to: peer) ?? false + } + bridge.availableBridgePeers = { [weak bleService] in + bleService?.reachableBridgePeers() ?? [] + } + bridge.broadcastToMesh = { [weak bleService] payload in + bleService?.broadcastNostrCarrier(payload) + } + bridge.injectInbound = { [weak viewModel] inbound in + viewModel?.handlePublicMessage(BitchatMessage( + id: inbound.messageID, + sender: inbound.senderNickname, + content: inbound.content, + timestamp: inbound.timestamp, + isRelay: false, + senderPeerID: PeerID(bridge: inbound.senderPubkey), + isBridged: true + )) + } + bridge.isMessageSeenLocally = { [weak viewModel] messageID in + viewModel?.publicConversationContainsMessage(withID: messageID, in: .mesh) ?? false + } + bridge.deriveIdentity = { cell in + try idBridge.deriveIdentity(forBridgeRendezvous: cell) + } + bridge.myNickname = { [weak viewModel] in viewModel?.nickname ?? "" } + + // The `.bridge` capability + cell TLV advertise serving duty: "send + // me deposits, and this is the island's cell". One switch: bridging + // with a known cell is serving (deposits queue through connectivity + // gaps, so the advertisement doesn't flap with the relays). + let updateAdvertisement: @MainActor () -> Void = { [weak bleService] in + let advertise = BridgeService.shared.isEnabled + && BridgeService.shared.activeCell != nil + bleService?.setLocalBridgeGeohash(advertise ? BridgeService.shared.activeCell : nil) + bleService?.setLocalCapability(.bridge, enabled: advertise) + } + bridge.onEnabledChanged = { [weak viewModel] enabled in + updateAdvertisement() + // One switch collapses further: the bridge toggle also drives + // the geohash-channel gateway — bridging with internet means + // sharing it with the mesh around you, full stop. + GatewayService.shared.setEnabled(enabled) + // Flipping the switch is the user-initiated moment to ask for + // location if it was never asked; otherwise the bridge sits + // cell-less with only a settings caption explaining why. + if enabled, viewModel?.locationManager.permissionState == .notDetermined { + viewModel?.locationManager.enableLocationChannels() + } + } + bridge.onActiveCellChanged = { _ in updateAdvertisement() } + // Align a persisted split state (e.g. gateway enabled back when it + // had its own toggle) to the single switch at launch. + if GatewayService.shared.isEnabled != bridge.isEnabled { + GatewayService.shared.setEnabled(bridge.isEnabled) + } + + // Location fixes (or losing them) move the rendezvous cell. + viewModel.locationManager.$availableChannels + .receive(on: DispatchQueue.main) + .sink { _ in BridgeService.shared.refreshRendezvous() } + .store(in: &viewModel.cancellables) + // The authorization callback lands asynchronously after launch; the + // bootstrap-time location request races it and silently no-ops, so + // re-enter when the permission state resolves (field bug: bridge + // stayed cell-less for a whole session). + viewModel.locationManager.$permissionState + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeService.shared.refreshRendezvous() } + .store(in: &viewModel.cancellables) + + // Apply the persisted toggle at launch. + if bridge.isEnabled { + bridge.refreshRendezvous() + updateAdvertisement() + } + } + + /// Wires courier-over-bridge (`BridgeCourierService`) to the relay + /// manager, the mesh transport's sealing/opening primitives, the courier + /// store, and the message router's deposit path. + func configureBridgeCourier() { + guard let bleService = viewModel.meshService as? BLEService else { return } + let courier = BridgeCourierService.shared + + courier.bridgeEnabled = { BridgeService.shared.isEnabled } + courier.relaysConnected = { NostrRelayManager.shared.isConnected } + courier.publishEvent = { event in + // Default (DM) relays: drops need the standing global relay set, + // not geo relays — sender and recipient share no cell. + NostrRelayManager.shared.sendEvent(event) + } + courier.openSubscription = { tagsHex in + NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) + NostrRelayManager.shared.subscribe( + filter: .courierDrops( + recipientTagsHex: tagsHex, + since: Date().addingTimeInterval(-CourierEnvelope.maxLifetimeSeconds) + ), + id: Self.courierDropSubscriptionID, + handler: { event in + BridgeCourierService.shared.handleDropEvent(event) + } + ) + } + courier.closeSubscription = { + NostrRelayManager.shared.unsubscribe(id: Self.courierDropSubscriptionID) + } + courier.myNoiseKey = { [weak bleService] in + bleService?.myNoiseStaticPublicKey() + } + courier.localVerifiedPeers = { [weak bleService] in + bleService?.verifiedPeersWithNoiseKeys() ?? [] + } + courier.sealEnvelope = { [weak bleService] content, messageID, recipientKey in + bleService?.sealBridgeCourierEnvelope(content, messageID: messageID, recipientNoiseKey: recipientKey) + } + courier.openEnvelope = { [weak bleService] envelope in + bleService?.openBridgedCourierEnvelope(envelope) + } + courier.deliverToPeer = { [weak bleService] envelope, peerID in + bleService?.deliverBridgedEnvelope(envelope, to: peerID) + } + courier.heldEnvelopes = { cooldown in + CourierStore.shared.envelopesForBridgePublish(cooldown: cooldown) + } + + viewModel.messageRouter.bridgeCourierDeposit = { content, messageID, recipientKey in + BridgeCourierService.shared.depositDrop(content: content, messageID: messageID, recipientNoiseKey: recipientKey) + } + // (depositDrop's Bool flows back so a dropped message shows the + // "carried" state — a drop's delivery ack can't return over radio.) + viewModel.messageRouter.startBridgeDepositSweep() + bleService.onVerifiedPeerAnnounce = { _ in + Task { @MainActor in + BridgeCourierService.shared.refreshAfterVerifiedAnnounce() + } + } + + // Relay connectivity gates everything; refresh (re)opens or closes. + // Deduped: refresh() resubscribes, and the raw publisher re-emits on + // every relay state recompute (6x in 300ms in field logs). + NostrRelayManager.shared.$isConnected + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeCourierService.shared.refresh() } + .store(in: &viewModel.cancellables) + // Toggle changes re-evaluate the watch set. + BridgeService.shared.$isEnabled + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { _ in BridgeCourierService.shared.refresh() } + .store(in: &viewModel.cancellables) + + courier.refresh() + } + + private static let bridgeSubscriptionID = "bridge-rendezvous" + private static let courierDropSubscriptionID = "bridge-courier-drops" + func bindTeleportState() { viewModel.locationManager.$teleported .receive(on: DispatchQueue.main) diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index a850f3e4..646d6fde 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -1,17 +1,36 @@ import SwiftUI +/// The sheet behind the "bitchat/" logo: a segmented Settings/Info surface. +/// Settings gathers every user preference (appearance, voice, connectivity +/// toggles, panic wipe); Info keeps the about content (how-to, features, +/// privacy, symbols legend). struct AppInfoView: View { @Environment(\.dismiss) var dismiss @ThemedPalette private var palette @AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @ObservedObject private var bridgeService = BridgeService.shared /// Supplies the mesh topology map data. Nil (previews, missing wiring) /// hides the topology row entirely. var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? + /// Wipes all local data. Nil (previews, missing wiring) hides the danger + /// zone entirely. + var onPanicWipe: (@MainActor () -> Void)? + @State private var showTopology = false @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled @State private var locationNotesEnabled = LocationNotesSettings.enabled @ObservedObject private var locationManager = LocationChannelManager.shared + /// Sticky across opens: first-ever open lands on Info (the gentler + /// introduction), and afterwards the sheet reopens wherever it was left. + @AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info + @State private var showPanicConfirmation = false + + private enum Pane: String { + case settings + case info + } private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix @@ -20,13 +39,48 @@ struct AppInfoView: View { private var textColor: Color { palette.primary } private var secondaryTextColor: Color { palette.secondary } - + // MARK: - Constants private enum Strings { static let appName: LocalizedStringKey = "app_info.app_name" static let tagline: LocalizedStringKey = "app_info.tagline" static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title" + /// New keys carry their English copy inline (defaultValue) until the + /// i18n pass lands them in the catalog; moved keys keep their homes. + enum Settings { + static let tabPickerLabel = String(localized: "app_info.tab.picker_label", defaultValue: "view", comment: "Accessibility label for the segmented control switching between the settings and info panes of the app info sheet") + static let tabSettings = String(localized: "app_info.tab.settings", defaultValue: "settings", comment: "Segmented control label for the settings pane of the app info sheet") + static let tabInfo = String(localized: "app_info.tab.info", defaultValue: "info", comment: "Segmented control label for the info pane of the app info sheet") + + static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing") + + static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings") + static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does") + static func bridgeCell(_ cell: String) -> String { + String( + format: String(localized: "app_info.settings.bridge.cell", defaultValue: "rendezvous cell: %@", comment: "Caption under the mesh bridge toggle showing the geohash cell the bridge is meeting on"), + locale: .current, + cell + ) + } + static let bridgeNoCell = String(localized: "app_info.settings.bridge.no_cell", defaultValue: "no rendezvous cell yet — needs location access or a nearby bridge peer", comment: "Caption under the mesh bridge toggle when the bridge is on but has no geohash cell to meet on") + + // Moved from LocationChannelsSheet; keys unchanged. (The former + // internet-gateway toggle is gone: the bridge switch drives all + // internet sharing, including geohash-channel gatewaying.) + static let torTitle: LocalizedStringKey = "location_channels.tor.title" + static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" + static let toggleOn: LocalizedStringKey = "common.toggle.on" + static let toggleOff: LocalizedStringKey = "common.toggle.off" + + static let dangerTitle = String(localized: "app_info.settings.danger.title", defaultValue: "DANGER ZONE", comment: "Section header (uppercase) for destructive actions in settings") + static let panicButton = String(localized: "app_info.settings.danger.panic_button", defaultValue: "panic wipe", comment: "Button in the settings danger zone that erases all local data after confirmation") + static let panicNote = String(localized: "app_info.settings.danger.panic_note", defaultValue: "erases all messages, keys, and identity. triple-tapping the bitchat/ logo does the same, instantly.", comment: "Caption under the panic wipe button explaining what it does and the triple-tap shortcut") + static let panicConfirmTitle = String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe") + static let panicConfirmAction = String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe") + } + enum Features { static let title: LocalizedStringKey = "app_info.features.title" static let offlineComm = AppInfoFeatureInfo( @@ -59,46 +113,47 @@ struct AppInfoView: View { title: "app_info.features.geohash.title", description: "app_info.features.geohash.description" ) + static let bridge = AppInfoFeatureInfo( + icon: "network", + resolvedTitle: String(localized: "app_info.features.bridge.title", defaultValue: "mesh bridging", comment: "Feature row title for the mesh bridge in the app info sheet"), + resolvedDescription: String(localized: "app_info.features.bridge.description", defaultValue: "links nearby mesh islands through the internet so one crowd isn't split by radio range", comment: "Feature row description for the mesh bridge in the app info sheet") + ) } enum Legend { static let title: LocalizedStringKey = "app_info.legend.title" /// Every glyph the peer lists and headers use, in one place — - /// nothing else in the app defines them. - static let items: [(icon: String, text: LocalizedStringKey)] = [ - ("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"), - ("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"), - ("globe", "app_info.legend.nostr"), - ("person", "app_info.legend.offline"), - ("mappin.and.ellipse", "app_info.legend.location_nearby"), - ("face.dashed", "app_info.legend.teleported"), - ("lock.fill", "app_info.legend.encrypted"), - ("lock.slash", "app_info.legend.encryption_failed"), - ("checkmark.seal.fill", "app_info.legend.verified"), - ("star.fill", "app_info.legend.favorite"), - ("envelope.fill", "app_info.legend.unread"), - ("nosign", "app_info.legend.blocked") + /// nothing else in the app defines them. A nil color renders in + /// the theme's primary text color. + static let items: [(icon: String, color: Color?, text: String)] = [ + ("antenna.radiowaves.left.and.right", nil, String(localized: "app_info.legend.mesh_connected")), + ("point.3.filled.connected.trianglepath.dotted", nil, String(localized: "app_info.legend.mesh_relayed")), + ("globe", nil, String(localized: "app_info.legend.nostr")), + ("network", Color.cyan, String(localized: "app_info.legend.bridged", defaultValue: "message arrived across a mesh bridge", comment: "Symbols legend entry for the cyan network glyph shown on messages carried across a mesh bridge")), + ("person", nil, String(localized: "app_info.legend.offline")), + ("mappin.and.ellipse", nil, String(localized: "app_info.legend.location_nearby")), + ("face.dashed", nil, String(localized: "app_info.legend.teleported")), + ("lock.fill", nil, String(localized: "app_info.legend.encrypted")), + ("lock.slash", nil, String(localized: "app_info.legend.encryption_failed")), + ("checkmark.seal.fill", nil, String(localized: "app_info.legend.verified")), + ("star.fill", nil, String(localized: "app_info.legend.favorite")), + ("envelope.fill", nil, String(localized: "app_info.legend.unread")), + ("nosign", nil, String(localized: "app_info.legend.blocked")) ] } enum Voice { static let title: LocalizedStringKey = "app_info.voice.title" - static let live = AppInfoFeatureInfo( - icon: "dot.radiowaves.left.and.right", - title: "app_info.voice.live.title", - description: "app_info.voice.live.description" - ) + // The live-voice title/description keys are referenced inline at + // the toggle (they ride the shared settingToggle now). } enum Location { - static let title: LocalizedStringKey = "app_info.location.title" static let notes = AppInfoFeatureInfo( icon: "mappin.and.ellipse", title: "app_info.location.notes.title", description: "app_info.location.notes.description" ) - static let enable: LocalizedStringKey = "app_info.location.enable" - static let openSettings: LocalizedStringKey = "app_info.location.open_settings" } enum Network { @@ -149,7 +204,7 @@ struct AppInfoView: View { } } - + var body: some View { #if os(macOS) VStack(spacing: 0) { @@ -165,8 +220,12 @@ struct AppInfoView: View { } .themedSurface(opacity: 0.95) - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() } @@ -178,8 +237,12 @@ struct AppInfoView: View { } #else NavigationView { - ScrollView { - infoContent + VStack(spacing: 0) { + panePicker + + ScrollView { + paneContent + } } .themedSheetBackground() .navigationBarTitleDisplayMode(.inline) @@ -197,33 +260,35 @@ struct AppInfoView: View { } #endif } - + + // MARK: - Pane switching + + private var panePicker: some View { + Picker(Strings.Settings.tabPickerLabel, selection: $selectedPane) { + Text(Strings.Settings.tabInfo).tag(Pane.info) + Text(Strings.Settings.tabSettings).tag(Pane.settings) + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal) + .padding(.top, 12) + } + @ViewBuilder - private var infoContent: some View { + private var paneContent: some View { + switch selectedPane { + case .settings: + settingsContent + case .info: + infoContent + } + } + + // MARK: - Settings pane + + @ViewBuilder + private var settingsContent: some View { VStack(alignment: .leading, spacing: 24) { - // Header - VStack(alignment: .center, spacing: 8) { - Text(Strings.appName) - .bitchatFont(size: 32, weight: .bold) - .foregroundColor(textColor) - - Text(Strings.tagline) - .bitchatFont(size: 16) - .foregroundColor(secondaryTextColor) - } - .frame(maxWidth: .infinity) - .padding(.vertical) - - // How to Use - VStack(alignment: .leading, spacing: 16) { - SectionHeader(Strings.HowToUse.title) - - Text(verbatim: Strings.HowToUse.paragraph) - .bitchatFont(size: 14) - .foregroundColor(textColor) - .fixedSize(horizontal: false, vertical: true) - } - // Appearance — single row: label left, theme chips right HStack(spacing: 12) { SectionHeader(Strings.appearanceTitle) @@ -248,39 +313,216 @@ struct AppInfoView: View { } } - // Voice - VStack(alignment: .leading, spacing: 16) { + // Voice — same card + IRC pill as every other toggle setting. + VStack(alignment: .leading, spacing: 12) { SectionHeader(Strings.Voice.title) - HStack(spacing: 0) { - FeatureRow(info: Strings.Voice.live) - Toggle(Strings.Voice.live.title, isOn: $liveVoiceEnabled) - .labelsHidden() - .tint(palette.accent) - .onChange(of: liveVoiceEnabled) { newValue in - PTTSettings.liveVoiceEnabled = newValue - } + settingsCard { + settingToggle( + title: Text("app_info.voice.live.title"), + subtitle: Text("app_info.voice.live.description"), + isOn: Binding( + get: { liveVoiceEnabled }, + set: { newValue in + liveVoiceEnabled = newValue + PTTSettings.liveVoiceEnabled = newValue + } + ) + ) } } - // Location (notes / dead drops) - VStack(alignment: .leading, spacing: 16) { - SectionHeader(Strings.Location.title) + // Connectivity: mesh bridge, internet gateway, tor routing + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.connectivityTitle) - HStack(spacing: 0) { - FeatureRow(info: Strings.Location.notes) - Toggle(Strings.Location.notes.title, isOn: $locationNotesEnabled) - .labelsHidden() - .tint(palette.accent) - .onChange(of: locationNotesEnabled) { newValue in - LocationNotesSettings.enabled = newValue - if newValue { - locationManager.enableLocationChannels() - } - } + settingsCard { + settingToggle( + title: Text(Strings.Settings.bridgeTitle), + subtitle: Text(Strings.Settings.bridgeSubtitle), + isOn: bridgeToggleBinding + ) + // Where the bridge meets: the geohash rendezvous cell, or + // a hint about why there isn't one yet (no location and no + // bridge peer advertising a cell). + if bridgeService.isEnabled { + Text(bridgeService.activeCell.map(Strings.Settings.bridgeCell) ?? Strings.Settings.bridgeNoCell) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + } } - locationPermissionRow + settingsCard { + settingToggle( + title: Text(Strings.Settings.torTitle), + subtitle: Text(Strings.Settings.torSubtitle), + isOn: torToggleBinding + ) + } + + // Location notes / dead drops (merged from main's flat + // layout into the shared card + pill style). Turning it on + // may need the location prompt; the permission control below + // covers the denied path. + settingsCard { + settingToggle( + title: Strings.Location.notes.title, + subtitle: Strings.Location.notes.description, + isOn: Binding( + get: { locationNotesEnabled }, + set: { newValue in + locationNotesEnabled = newValue + LocationNotesSettings.enabled = newValue + if newValue { + locationManager.enableLocationChannels() + } + } + ) + ) + } + + // Location powers the channels list and the bridge cell, so + // its control lives with the other connectivity settings. + // Platform reality shapes the three states: the app may only + // prompt while never-asked; granted/denied both flip in the + // system permission screen. + switch locationChannelsModel.permissionState { + case .authorized: + Button(action: SystemSettings.location.open) { + Text("location_channels.action.remove_access") + .bitchatFont(size: 12) + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + case .notDetermined: + Button(action: { locationChannelsModel.enableLocationChannels() }) { + Text("location_channels.action.request_permissions") + .bitchatFont(size: 12) + .foregroundColor(palette.accent) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(palette.accent.opacity(0.12)) + .cornerRadius(6) + } + .buttonStyle(.plain) + case .denied, .restricted: + settingsCard { + Text("location_channels.permission_denied") + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + Button("location_channels.action.open_settings", action: SystemSettings.location.open) + .buttonStyle(.plain) + .bitchatFont(size: 12) + .foregroundColor(palette.accent) + } + } + } + + // Danger zone + if onPanicWipe != nil { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(verbatim: Strings.Settings.dangerTitle) + + Button(action: { showPanicConfirmation = true }) { + Text(Strings.Settings.panicButton) + .bitchatFont(size: 12) + .foregroundColor(palette.alertRed) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .confirmationDialog( + Strings.Settings.panicConfirmTitle, + isPresented: $showPanicConfirmation, + titleVisibility: .visible + ) { + Button(Strings.Settings.panicConfirmAction, role: .destructive) { + onPanicWipe?() + } + Button("common.cancel", role: .cancel) {} + } + + Text(Strings.Settings.panicNote) + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .padding() + } + + private var bridgeToggleBinding: Binding { + Binding( + get: { bridgeService.isEnabled }, + set: { bridgeService.setEnabled($0) } + ) + } + + private var torToggleBinding: Binding { + Binding( + get: { locationChannelsModel.userTorEnabled }, + set: { locationChannelsModel.setUserTorEnabled($0) } + ) + } + + /// The padded card every connectivity setting sits in (moved look from + /// LocationChannelsSheet's toggle sections). + private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8, content: content) + .padding(12) + .background(palette.secondary.opacity(0.12)) + .cornerRadius(8) + } + + /// A title+subtitle row driving an IRC-style on/off pill — the one + /// toggle style every setting uses. + private func settingToggle(title: Text, subtitle: Text, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + VStack(alignment: .leading, spacing: 2) { + title + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(textColor) + subtitle + .bitchatFont(size: 11) + .foregroundColor(secondaryTextColor) + } + } + .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.Settings.toggleOn, offLabel: Strings.Settings.toggleOff)) + } + + // MARK: - Info pane + + @ViewBuilder + private var infoContent: some View { + VStack(alignment: .leading, spacing: 24) { + // Header + VStack(alignment: .center, spacing: 8) { + Text(Strings.appName) + .bitchatFont(size: 32, weight: .bold) + .foregroundColor(textColor) + + Text(Strings.tagline) + .bitchatFont(size: 16) + .foregroundColor(secondaryTextColor) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + + // How to Use + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.HowToUse.title) + + Text(verbatim: Strings.HowToUse.paragraph) + .bitchatFont(size: 14) + .foregroundColor(textColor) + .fixedSize(horizontal: false, vertical: true) } // Network diagnostics @@ -314,6 +556,8 @@ struct AppInfoView: View { FeatureRow(info: Strings.Features.extendedRange) + FeatureRow(info: Strings.Features.bridge) + FeatureRow(info: Strings.Features.favorites) FeatureRow(info: Strings.Features.geohash) @@ -340,7 +584,7 @@ struct AppInfoView: View { HStack(alignment: .top, spacing: 12) { Image(systemName: item.icon) .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) + .foregroundColor(item.color ?? textColor) .frame(width: 30) Text(item.text) @@ -358,52 +602,44 @@ struct AppInfoView: View { } } -private extension AppInfoView { - /// One status/action line under the notes toggle so the location - /// requirement is actionable right here instead of only in the channel - /// sheet. - @ViewBuilder - var locationPermissionRow: some View { - if locationNotesEnabled { - switch locationManager.permissionState { - case .notDetermined: - Button(Strings.Location.enable) { - locationManager.enableLocationChannels() - } - .buttonStyle(.plain) - .bitchatFont(size: 13) - .foregroundColor(palette.accent) - case .denied, .restricted: - Button(Strings.Location.openSettings, action: SystemSettings.location.open) - .buttonStyle(.plain) - .bitchatFont(size: 13) - .foregroundColor(palette.accent) - case .authorized: - // Granted needs no status line — the toggle being on says it. - EmptyView() - } - } +struct AppInfoFeatureInfo { + let icon: String + let title: Text + let description: Text + + /// Catalog-backed strings (existing keys). + init(icon: String, title: LocalizedStringKey, description: LocalizedStringKey) { + self.icon = icon + self.title = Text(title) + self.description = Text(description) + } + + /// Pre-resolved strings — new keys that carry their English defaultValue + /// inline until the i18n pass adds them to the catalog. + init(icon: String, resolvedTitle: String, resolvedDescription: String) { + self.icon = icon + self.title = Text(resolvedTitle) + self.description = Text(resolvedDescription) } } -struct AppInfoFeatureInfo { - let icon: String - let title: LocalizedStringKey - let description: LocalizedStringKey -} - struct SectionHeader: View { - let title: LocalizedStringKey + private let title: Text @ThemedPalette private var palette private var textColor: Color { palette.primary } init(_ title: LocalizedStringKey) { - self.title = title + self.title = Text(title) } - + + /// For pre-resolved strings (new keys with inline defaultValue). + init(verbatim title: String) { + self.title = Text(title) + } + var body: some View { - Text(title) + title .bitchatFont(size: 16, weight: .bold) .foregroundColor(textColor) .padding(.top, 8) @@ -424,18 +660,18 @@ struct FeatureRow: View { .font(.bitchatSystem(size: 20)) .foregroundColor(textColor) .frame(width: 30) - + VStack(alignment: .leading, spacing: 4) { - Text(info.title) + info.title .bitchatFont(size: 14, weight: .semibold) .foregroundColor(textColor) - - Text(info.description) + + info.description .bitchatFont(size: 12) .foregroundColor(secondaryTextColor) .fixedSize(horizontal: false, vertical: true) } - + Spacer() } } @@ -443,14 +679,17 @@ struct FeatureRow: View { #Preview("Default") { AppInfoView() + .environmentObject(LocationChannelsModel()) } #Preview("Dynamic Type XXL") { AppInfoView() + .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge) } #Preview("Dynamic Type XS") { AppInfoView() + .environmentObject(LocationChannelsModel()) .environment(\.sizeCategory, .extraSmall) } diff --git a/bitchat/Views/BridgePeopleList.swift b/bitchat/Views/BridgePeopleList.swift new file mode 100644 index 00000000..89162397 --- /dev/null +++ b/bitchat/Views/BridgePeopleList.swift @@ -0,0 +1,76 @@ +// +// BridgePeopleList.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import SwiftUI + +/// Shared section header for the people sheet: a small glyph + label pair, +/// identical shape for every section (#mesh, across the bridge, …). +struct PeopleSectionHeader: View { + @ThemedPalette private var palette + let icon: String + let iconColor: Color + let title: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.bitchatSystem(size: 10)) + .foregroundColor(iconColor) + Text(verbatim: title) + .bitchatFont(size: 11, weight: .semibold) + .foregroundColor(palette.secondary) + } + .padding(.horizontal) + .padding(.top, 12) + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + } +} + +/// The people-sheet section for participants visible across the mesh bridge: +/// same place, beyond radio range. Display-only in v1 — bridged identities +/// are per-cell rendezvous keys with no DM route yet. +struct BridgePeopleList: View { + @ObservedObject private var bridgeService = BridgeService.shared + @ThemedPalette private var palette + + private enum Strings { + static let sectionTitle = String(localized: "bridge_people.section_title", defaultValue: "across the bridge", comment: "Section header in the people sheet for participants reachable via the mesh bridge") + static let rowHint = String(localized: "bridge_people.accessibility.row_hint", defaultValue: "In your area, connected through the bridge", comment: "Accessibility hint for a person listed in the bridge section of the people sheet") + } + + var body: some View { + // Not gated on the toggle: bridged people arrive over passive radio + // (a serving neighbor's carriers) even while this device's own + // bridge is off — whoever is visible in the timeline belongs in the + // sheet. + if !bridgeService.bridgedParticipants.isEmpty { + VStack(alignment: .leading, spacing: 0) { + PeopleSectionHeader( + icon: "network", + iconColor: Color.cyan.opacity(0.9), + title: Strings.sectionTitle + ) + + ForEach(bridgeService.bridgedParticipants) { person in + HStack(spacing: 4) { + Text(person.displayName) + .bitchatFont(size: 14) + .foregroundColor(palette.primary) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityHint(Strings.rowHint) + } + } + } + } +} diff --git a/bitchat/Views/Components/IRCToggleStyle.swift b/bitchat/Views/Components/IRCToggleStyle.swift new file mode 100644 index 00000000..0b97bf08 --- /dev/null +++ b/bitchat/Views/Components/IRCToggleStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +/// IRC-flavored toggle: the whole row is one button and the state is spelled +/// out as an on/off pill instead of a system switch. Shared by the settings +/// surfaces (App Info's connectivity toggles and friends). +struct IRCToggleStyle: ToggleStyle { + let accent: Color + let onLabel: LocalizedStringKey + let offLabel: LocalizedStringKey + + func makeBody(configuration: Configuration) -> some View { + Button(action: { configuration.isOn.toggle() }) { + HStack(spacing: 12) { + configuration.label + Spacer() + Text(configuration.isOn ? onLabel : offLabel) + .textCase(.uppercase) + .bitchatFont(size: 12, weight: .semibold) + .foregroundColor(configuration.isOn ? accent : .secondary) + .padding(.vertical, 4) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) + ) + } + } + .buttonStyle(.plain) + } +} diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 4c96b201..497b4280 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -50,6 +50,15 @@ struct TextMessageView: View { .padding(.trailing, 4) .accessibilityHidden(true) } + if message.isBridged { + Image(systemName: "network") + .font(.bitchatSystem(size: 8)) + .foregroundColor(Color.cyan.opacity(0.75)) + .padding(.trailing, 4) + .accessibilityLabel( + String(localized: "content.accessibility.bridged_message", defaultValue: "Arrived across a mesh bridge", comment: "Accessibility label for the glyph marking a message that arrived across a mesh bridge") + ) + } Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift index 6ebcd756..9913ce03 100644 --- a/bitchat/Views/ContentComposerView.swift +++ b/bitchat/Views/ContentComposerView.swift @@ -7,6 +7,7 @@ struct ContentComposerView: View { @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @ObservedObject private var bridgeService = BridgeService.shared @Environment(\.appTheme) private var theme @ThemedPalette private var palette @@ -89,6 +90,10 @@ struct ContentComposerView: View { } HStack(alignment: .center, spacing: 4) { + if showsNearbyOnlyToggle { + nearbyOnlyToggle + } + if conversationUIModel.canSendMediaInCurrentContext { attachmentButton } @@ -108,6 +113,41 @@ struct ContentComposerView: View { } private extension ContentComposerView { + /// The nearby-only scope toggle appears only where it means something: + /// the public mesh channel with the bridge on. + var showsNearbyOnlyToggle: Bool { + guard bridgeService.isEnabled, + privateConversationModel.selectedHeaderState == nil, + case .mesh = locationChannelsModel.selectedChannel else { + return false + } + return true + } + + /// Scope control for outgoing messages: bridged (default, crosses to + /// other islands in this area) vs nearby-only (radio range, no internet + /// copy exists for any gateway to carry). + var nearbyOnlyToggle: some View { + Button(action: { bridgeService.nearbyOnly.toggle() }) { + Image(systemName: bridgeService.nearbyOnly ? "antenna.radiowaves.left.and.right" : "network") + .font(.bitchatSystem(size: 16)) + .foregroundColor(bridgeService.nearbyOnly ? palette.secondary : Color.cyan.opacity(0.9)) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + bridgeService.nearbyOnly + ? String(localized: "content.accessibility.nearby_only_on", defaultValue: "Nearby only: messages stay within radio range", comment: "Accessibility label for the compose scope toggle when messages stay local") + : String(localized: "content.accessibility.nearby_only_off", defaultValue: "Bridged: messages also reach people across the bridge", comment: "Accessibility label for the compose scope toggle when messages cross the mesh bridge") + ) + .help( + bridgeService.nearbyOnly + ? String(localized: "content.composer.nearby_only_on", defaultValue: "Nearby only — this message won't cross the bridge", comment: "Tooltip for the compose scope toggle when messages stay local") + : String(localized: "content.composer.nearby_only_off", defaultValue: "Bridged — reaches people beyond radio range in this area", comment: "Tooltip for the compose scope toggle when messages cross the mesh bridge") + ) + } + /// States where a message will land: the DM partner's name for private /// chats, the channel (and its public nature) otherwise — so a stressed /// user never has to guess who can read what they're typing. diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 792e2870..96b548fb 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -6,6 +6,7 @@ struct ContentHeaderView: View { @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var boardAlertsModel: BoardAlertsModel + @ObservedObject private var bridgeService = BridgeService.shared @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.appTheme) private var theme @ThemedPalette private var palette @@ -21,9 +22,6 @@ struct ContentHeaderView: View { /// Courier envelopes this device is carrying for offline third parties. @State private var carriedMailCount = 0 - /// Unified notices sheet (board posts + location notes) for the current - /// channel context. - /// Board posts mirrored from the store so the pin icon can show when the /// current scope has notices. @State private var boardPosts: [BoardPostPacket] = [] @@ -32,6 +30,12 @@ struct ContentHeaderView: View { /// timeline is showing) — they should light the pin too. @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + /// The bridged-people count belongs to the mesh channel only. + private var showBridgedPeerCount: Bool { + if case .location = locationChannelsModel.selectedChannel { return false } + return bridgeService.bridgedPeerCount > 0 + } + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -101,12 +105,17 @@ struct ContentHeaderView: View { if case .location = locationChannelsModel.selectedChannel { return peerListModel.visibleGeohashPeerCount } - return countAndColor.0 + // One number for the whole room: radio-reachable peers plus + // people across the bridge (visible via carriers even while + // this device's own bridge is off). The sheet breaks it down. + return countAndColor.0 + bridgeService.bridgedPeerCount }() HStack(spacing: 2) { if locationChannelsModel.gatewayEnabled { - Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) { + // The gateway toggle lives in the App Info settings pane + // now, so the indicator deep-links there. + Button(action: { appChromeModel.presentAppInfo() }) { Image(systemName: "globe") .font(.bitchatSystem(size: 12)) .foregroundColor(palette.secondary.opacity(0.8)) @@ -117,7 +126,7 @@ struct ContentHeaderView: View { String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator") ) .accessibilityHint( - String(localized: "content.accessibility.gateway_hint", defaultValue: "Opens channel settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the channel sheet") + String(localized: "content.accessibility.gateway_settings_hint", defaultValue: "Opens settings to turn the gateway on or off", comment: "Accessibility hint for the internet gateway indicator explaining a tap opens the settings sheet") ) .help( String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator") @@ -265,11 +274,17 @@ struct ContentHeaderView: View { ) ) // Connected-vs-nobody is otherwise encoded only in the icon's - // color; say it. + // color; say it. With a live bridge, also say who's across it. .accessibilityValue( - headerPeersReachable - ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") - : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable") + showBridgedPeerCount + ? String( + format: String(localized: "content.accessibility.bridged_count", defaultValue: "%lld more people across the bridge", comment: "Accessibility value announcing the number of people reachable via the mesh bridge"), + locale: .current, + bridgeService.bridgedPeerCount + ) + : (headerPeersReachable + ? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable") + : String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")) ) } .layoutPriority(3) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a6a68c82..0cbd27d1 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -174,32 +174,13 @@ private struct ContentPeopleListView: View { } } - let activeText = String.localizedStringWithFormat( - String(localized: "%@ active", comment: "Count of active users in the people sheet"), - "\(peopleSheetActiveCount)" - ) - - if let subtitle = peopleSheetSubtitle { - let subtitleColor: Color = { - switch locationChannelsModel.selectedChannel { - case .mesh: - return palette.accentBlue - case .location: - return palette.locationAccent - } - }() - - HStack(spacing: 6) { - Text(subtitle) - .foregroundColor(subtitleColor) - Text(activeText) - .foregroundColor(palette.secondary) - } - .bitchatFont(size: 12) - } else { - Text(activeText) + // The mesh sheet titles its sections inline (#mesh / across + // the bridge / groups) — no subtitle or count up here. + // Location channels keep their geohash subtitle. + if case .location(let channel) = locationChannelsModel.selectedChannel { + Text(verbatim: "#\(channel.geohash.lowercased())") .bitchatFont(size: 12) - .foregroundColor(palette.secondary) + .foregroundColor(palette.locationAccent) } } .padding(.horizontal, 16) @@ -208,7 +189,10 @@ private struct ContentPeopleListView: View { .themedSurface() ScrollView { - VStack(alignment: .leading, spacing: 6) { + // spacing 0: every section supplies its own rhythm (header + // top 12 / bottom 4, rows vertical 4), so inter-child spacing + // here would make the first section's gap read differently. + VStack(alignment: .leading, spacing: 0) { if case .location = locationChannelsModel.selectedChannel { GeohashPeopleList( onTapPerson: { @@ -216,12 +200,10 @@ private struct ContentPeopleListView: View { } ) } else { - GroupChatList( - groups: peerListModel.groupRows, - onTapGroup: { peerID in - peerListModel.startConversation(with: peerID) - showSidebar = true - } + PeopleSectionHeader( + icon: "antenna.radiowaves.left.and.right", + iconColor: palette.accentBlue, + title: "#mesh" ) MeshPeerList( onTapPeer: { peerID in @@ -242,9 +224,24 @@ private struct ContentPeopleListView: View { } } ) + // People in this area but beyond radio range, and + // private groups: one sheet for the whole room. + BridgePeopleList() + GroupChatList( + groups: peerListModel.groupRows, + onTapGroup: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + } + ) } } .padding(.top, 4) + // Full width even when every row is narrow (empty mesh, no + // groups): without this the VStack hugs its widest child and + // the ScrollView centers it — headers and empty states + // floated mid-screen on iPhone. + .frame(maxWidth: .infinity, alignment: .leading) .id(peerListModel.renderID) } } @@ -260,23 +257,6 @@ private extension ContentPeopleListView { String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() } - var peopleSheetSubtitle: String? { - switch locationChannelsModel.selectedChannel { - case .mesh: - return "#mesh" - case .location(let channel): - return "#\(channel.geohash.lowercased())" - } - } - - var peopleSheetActiveCount: Int { - switch locationChannelsModel.selectedChannel { - case .mesh: - return peerListModel.reachableMeshPeerCount - case .location: - return peerListModel.visibleGeohashPeerCount - } - } } private struct ContentPrivateChatSheetView: View { @@ -542,10 +522,14 @@ private struct ContentPrivateHeaderInfoButton: View { .foregroundColor(.purple) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) case .offline: - // Absence of a glyph was the only offline signal; say it. - Text("mesh_peers.state.offline") - .bitchatFont(size: 11) + // Slashed variant of the connected glyph — offline as + // the negation of connected, no text label (a leading + // one read as part of the name: "sin conexión bob"). + // VoiceOver still says it. + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.bitchatSystem(size: 14)) .foregroundColor(palette.secondary) + .accessibilityLabel(String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")) } } @@ -580,6 +564,7 @@ private struct ContentPrivateHeaderInfoButton: View { ) ) } + } } .buttonStyle(.plain) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index a40213b8..fc1a6766 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -35,6 +35,7 @@ struct ContentView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @State private var messageText = "" @@ -150,7 +151,11 @@ struct ContentView: View { #endif } .sheet(isPresented: $appChromeModel.isAppInfoPresented) { - AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() }) + AppInfoView( + topologyProvider: { appChromeModel.meshTopologyDisplayModel() }, + onPanicWipe: { appChromeModel.panicClearAllData() } + ) + .environmentObject(locationChannelsModel) } .sheet(isPresented: Binding( get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift index 8b62d8a2..4dff5c1d 100644 --- a/bitchat/Views/GroupChatList.swift +++ b/bitchat/Views/GroupChatList.swift @@ -21,20 +21,15 @@ struct GroupChatList: View { var body: some View { if !groups.isEmpty { VStack(alignment: .leading, spacing: 0) { - Text(Strings.header) - .bitchatFont(size: 11, weight: .medium) - .foregroundColor(palette.secondary) - .padding(.horizontal) - .padding(.top, 10) - .padding(.bottom, 2) - .accessibilityAddTraits(.isHeader) + // Same glyph+label header shape as #mesh / across the bridge. + PeopleSectionHeader( + icon: "person.3.fill", + iconColor: palette.primary, + title: Strings.header + ) ForEach(groups) { group in HStack(spacing: 4) { - Image(systemName: "person.3.fill") - .font(.bitchatSystem(size: 10)) - .foregroundColor(palette.primary) - Text("#\(group.name)") .bitchatFont(size: 14) .foregroundColor(palette.primary) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index c8f3241b..fe916cf2 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -20,15 +20,9 @@ struct LocationChannelsSheet: View { static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied" static let openSettings: LocalizedStringKey = "location_channels.action.open_settings" static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby" + 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" - static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access" - static let torTitle: LocalizedStringKey = "location_channels.tor.title" - static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle" - static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title" - static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle" - static let toggleOn: LocalizedStringKey = "common.toggle.on" - static let toggleOff: LocalizedStringKey = "common.toggle.off" static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid") static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it") @@ -218,7 +212,7 @@ struct LocationChannelsSheet: View { } .padding(.vertical, 6) } - } else { + } else if locationChannelsModel.permissionState == .authorized { sectionDivider HStack(spacing: 8) { ProgressView() @@ -227,6 +221,15 @@ struct LocationChannelsSheet: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 10) + } else { + // No permission means no fix is coming: an honest hint + // beats a spinner that would never finish. + sectionDivider + Text(Strings.grantToFind) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) } sectionDivider @@ -240,24 +243,6 @@ struct LocationChannelsSheet: View { .padding(.vertical, 8) } - if locationChannelsModel.permissionState == .authorized { - sectionDivider - torToggleSection - .padding(.top, 12) - gatewayToggleSection - .padding(.top, 8) - Button(action: SystemSettings.location.open) { - Text(Strings.removeAccess) - .bitchatFont(size: 12) - .foregroundColor(palette.alertRed) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.red.opacity(0.08)) - .cornerRadius(6) - } - .buttonStyle(.plain) - .padding(.vertical, 8) - } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) @@ -482,94 +467,14 @@ struct LocationChannelsSheet: View { } } -// MARK: - TOR Toggle & Standardized Colors +// MARK: - Standardized Colors +// (The tor and internet-gateway toggles moved to AppInfoView's Settings pane; +// IRCToggleStyle now lives in Views/Components.) extension LocationChannelsSheet { - private var torToggleBinding: Binding { - Binding( - get: { locationChannelsModel.userTorEnabled }, - set: { locationChannelsModel.setUserTorEnabled($0) } - ) - } - - private var torToggleSection: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: torToggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(Strings.torTitle) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(palette.primary) - Text(Strings.torSubtitle) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) - } - .padding(12) - .background(palette.secondary.opacity(0.12)) - .cornerRadius(8) - } - - private var gatewayToggleBinding: Binding { - Binding( - get: { locationChannelsModel.gatewayEnabled }, - set: { locationChannelsModel.setGatewayEnabled($0) } - ) - } - - private var gatewayToggleSection: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle(isOn: gatewayToggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(Strings.gatewayTitle) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(palette.primary) - Text(Strings.gatewaySubtitle) - .bitchatFont(size: 11) - .foregroundColor(palette.secondary) - } - } - .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) - } - .padding(12) - .background(palette.secondary.opacity(0.12)) - .cornerRadius(8) - } - private var standardGreen: Color { palette.primary } private var standardBlue: Color { palette.accentBlue } } -private struct IRCToggleStyle: ToggleStyle { - let accent: Color - let onLabel: LocalizedStringKey - let offLabel: LocalizedStringKey - - func makeBody(configuration: Configuration) -> some View { - Button(action: { configuration.isOn.toggle() }) { - HStack(spacing: 12) { - configuration.label - Spacer() - Text(configuration.isOn ? onLabel : offLabel) - .textCase(.uppercase) - .bitchatFont(size: 12, weight: .semibold) - .foregroundColor(configuration.isOn ? accent : .secondary) - .padding(.vertical, 4) - .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(accent.opacity(configuration.isOn ? 0.18 : 0.08)) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1) - ) - } - } - .buttonStyle(.plain) - } -} - // MARK: - Coverage helpers extension LocationChannelsSheet { private func coverageString(forPrecision len: Int) -> String { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index b09ef476..1d94af5f 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -44,13 +44,14 @@ struct MeshPeerList: View { } if peerListModel.meshRows.isEmpty { - VStack(alignment: .leading, spacing: 0) { - Text(Strings.noneNearby) - .bitchatFont(size: 14) - .foregroundColor(palette.secondary) - .padding(.horizontal) - .padding(.top, 12) - } + // Match the section's row rhythm (same size, indent, and vertical + // padding as a peer row) so the empty state reads as the list's + // only line, not a floating caption. + Text(Strings.noneNearby) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.vertical, 4) } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. String { let now = Date() if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - let rel = relativeFormatter.string(from: date, to: now) ?? "" - return rel.isEmpty ? "" : "\(rel) ago" + // The whole "3 hr ago" phrase must come from the formatter — + // gluing an English "ago" onto a localized duration ships the + // wrong word order to most locales ("hace 3 h", "vor 3 Std"). + return relativeFormatter.localizedString(for: date, relativeTo: now) } let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date) } - private static let relativeFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated - f.collapsesLargestUnit = true return f }() diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 25aa8300..28dabfa9 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -87,6 +87,11 @@ private final class MockChatOutgoingContext: ChatOutgoingContext { sentGeohashContexts.append(context) } + private(set) var bridgedMessages: [(content: String, messageID: String)] = [] + func bridgeOutgoingPublicMessage(_ content: String, messageID: String) { + bridgedMessages.append((content, messageID)) + } + // Geohash identity struct IdentityUnavailable: Error {} var deriveNostrIdentityError: Error? diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift index cedcc15f..00f210f3 100644 --- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift +++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift @@ -672,6 +672,50 @@ struct MessageRouterCourierTests { #expect(carried == ["m1"]) } + /// Field-found: a DM sent while the recipient sits in the BLE + /// reachability retention window (radio gone, still "reachable" for a + /// minute) trusts the mesh and skips every deposit — and nothing + /// retried. The periodic sweep must publish the bridge drop once the + /// window lapses. + @Test @MainActor + func sweepDropsQueuedMessageOnceReachabilityLapses() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let transport = CourierCaptureTransport() + transport.reachablePeers = [bobID] // retention window: stale but "reachable" + transport.promptDelivery = true + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { _ in false } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var drops: [(content: String, messageID: String, key: Data)] = [] + router.bridgeCourierDeposit = { content, messageID, key in + drops.append((content, messageID, key)) + return true + } + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m9") + #expect(drops.isEmpty) // send trusted the (stale) mesh reachability + + // Still inside the window: the sweep must not spam relays. + router.retryBridgeCourierDeposits() + #expect(drops.isEmpty) + + // Window lapses; the sweep publishes the retained copy as a drop and + // the sender's message shows "carried" (its ack has no radio route). + transport.reachablePeers = [] + router.retryBridgeCourierDeposits() + #expect(drops.count == 1) + #expect(drops.first?.messageID == "m9") + #expect(drops.first?.content == "hi bob") + #expect(drops.first?.key == bobKey) + #expect(carried == ["m9"]) + } + @Test @MainActor func noCourierDepositWithoutKnownRecipientKey() { let transport = CourierCaptureTransport() diff --git a/bitchatTests/Protocols/BridgeWireFormatTests.swift b/bitchatTests/Protocols/BridgeWireFormatTests.swift new file mode 100644 index 00000000..95e56920 --- /dev/null +++ b/bitchatTests/Protocols/BridgeWireFormatTests.swift @@ -0,0 +1,149 @@ +// +// BridgeWireFormatTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Bridge wire formats") +struct BridgeWireFormatTests { + // MARK: - Announce bridgeGeohash TLV + + @Test func announceRoundTripsBridgeGeohash() throws { + let packet = AnnouncementPacket( + nickname: "gw", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil, + capabilities: [.bridge, .gateway], + bridgeGeohash: "u4pruy" + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == "u4pruy") + #expect(decoded.capabilities?.contains(.bridge) == true) + } + + @Test func announceWithoutBridgeCellDecodesNil() throws { + let packet = AnnouncementPacket( + nickname: "plain", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil + ) + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == nil) + } + + @Test func announceRejectsOversizedBridgeCellAtEncode() throws { + let packet = AnnouncementPacket( + nickname: "gw", + noisePublicKey: Data(repeating: 1, count: 32), + signingPublicKey: Data(repeating: 2, count: 32), + directNeighbors: nil, + bridgeGeohash: String(repeating: "u", count: 13) + ) + // Oversized cell is silently omitted, not a hard failure. + let encoded = try #require(packet.encode()) + let decoded = try #require(AnnouncementPacket.decode(from: encoded)) + #expect(decoded.bridgeGeohash == nil) + } + + // MARK: - Carrier directions + + @Test func bridgeCarrierDirectionsRoundTrip() throws { + for direction in [NostrCarrierPacket.Direction.toBridge, .fromBridge] { + let packet = try #require(NostrCarrierPacket( + direction: direction, + geohash: "u4pruy", + eventJSON: Data("{\"id\":\"x\"}".utf8) + )) + let encoded = try #require(packet.encode()) + let decoded = try #require(NostrCarrierPacket.decode(encoded)) + #expect(decoded.direction == direction) + #expect(decoded.geohash == "u4pruy") + } + } + + // MARK: - BitchatMessage bridged flag + // (Binary round-trip lives in BitFoundation's own tests — + // `toBinaryPayload` is internal to the package.) + + @Test func bridgedFlagSurvivesCodableRoundTrip() throws { + let message = BitchatMessage( + sender: "far-friend", + content: "hi", + timestamp: Date(), + isRelay: false, + isBridged: true + ) + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: data) + #expect(decoded.isBridged) + } + + @Test func legacyJSONWithoutBridgedFlagDecodes() throws { + let plain = BitchatMessage( + sender: "old-client", + content: "hi", + timestamp: Date(), + isRelay: false + ) + let encoded = try JSONEncoder().encode(plain) + var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + json.removeValue(forKey: "isBridged") + let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(!decoded.isBridged) + } + + @Test func bridgePeerIDParsesAndClassifies() { + let peerID = PeerID(str: "bridge:deadbeefcafe0123") + #expect(peerID.isBridge) + #expect(peerID.bare == "deadbeefcafe0123") + #expect(!peerID.isGeoChat) + } +} + +@Suite("Courier store bridge publish") +struct CourierStoreBridgePublishTests { + private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore { + CourierStore(persistsToDisk: false, now: now) + } + + private func makeEnvelope(now: Date = Date()) -> CourierEnvelope { + CourierEnvelope( + recipientTag: Data(repeating: 3, count: 16), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data(repeating: 9, count: 64), + copies: 4 + ) + } + + @Test func bridgePublishIsNonDestructiveAndCooledDown() { + var currentDate = Date() + let store = makeStore(now: { currentDate }) + #expect(store.deposit(makeEnvelope(now: currentDate), from: Data(repeating: 5, count: 32))) + + let first = store.envelopesForBridgePublish(cooldown: 600) + #expect(first.count == 1) + // The relay copy is carry-only regardless of stored spray budget. + #expect(first.first?.copies == 1) + // (Non-destructiveness is proven below: the same envelope is + // eligible again after the cooldown. `carriedCount` publishes + // asynchronously, so it is not asserted here.) + + // Within cooldown: nothing to publish. + #expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty) + + // After cooldown: eligible again. + currentDate = currentDate.addingTimeInterval(601) + #expect(store.envelopesForBridgePublish(cooldown: 600).count == 1) + } +} diff --git a/bitchatTests/Services/BridgeCourierServiceTests.swift b/bitchatTests/Services/BridgeCourierServiceTests.swift new file mode 100644 index 00000000..13bcbda0 --- /dev/null +++ b/bitchatTests/Services/BridgeCourierServiceTests.swift @@ -0,0 +1,284 @@ +// +// BridgeCourierServiceTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import CryptoKit +import Foundation +import Testing +@testable import bitchat + +@Suite("Courier over the bridge") +@MainActor +struct BridgeCourierServiceTests { + /// Closure-injected harness around `BridgeCourierService`. + @MainActor + private final class Fixture { + var bridgeOn = true + var relaysConnected = true + var myKey: Data? = Fixture.randomKey() + var localPeers: [(peerID: PeerID, noiseKey: Data)] = [] + var held: [CourierEnvelope] = [] + var sealResult: CourierEnvelope? + + private(set) var publishedEvents: [NostrEvent] = [] + private(set) var openedSubscriptions: [[String]] = [] + private(set) var closedSubscriptions = 0 + private(set) var openedEnvelopes: [CourierEnvelope] = [] + private(set) var delivered: [(envelope: CourierEnvelope, peer: PeerID)] = [] + private(set) var sealRequests: [(content: String, messageID: String, key: Data)] = [] + private(set) var heldCooldowns: [TimeInterval] = [] + + let service: BridgeCourierService + + init() { + service = BridgeCourierService() + service.bridgeEnabled = { [weak self] in self?.bridgeOn ?? false } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.publishEvent = { [weak self] event in self?.publishedEvents.append(event) } + service.openSubscription = { [weak self] tags in self?.openedSubscriptions.append(tags) } + service.closeSubscription = { [weak self] in self?.closedSubscriptions += 1 } + service.myNoiseKey = { [weak self] in self?.myKey } + service.localVerifiedPeers = { [weak self] in self?.localPeers ?? [] } + service.sealEnvelope = { [weak self] content, messageID, key in + self?.sealRequests.append((content, messageID, key)) + return self?.sealResult + } + service.openEnvelope = { [weak self] envelope in self?.openedEnvelopes.append(envelope) } + service.deliverToPeer = { [weak self] envelope, peer in self?.delivered.append((envelope, peer)) } + service.heldEnvelopes = { [weak self] cooldown in + self?.heldCooldowns.append(cooldown) + return self?.held ?? [] + } + service.scheduleTimer = { _, _ in } // timers driven manually + } + + static func randomKey() -> Data { + Data((0..<32).map { _ in UInt8.random(in: 0...255) }) + } + } + + private func makeEnvelope(recipientKey: Data, ciphertext: Data = Data(repeating: 7, count: 64)) -> CourierEnvelope { + CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ), + expiry: UInt64((Date().timeIntervalSince1970 + 3600) * 1000), + ciphertext: ciphertext, + copies: 1 + ) + } + + private func makeDropEvent(for envelope: CourierEnvelope) throws -> NostrEvent { + let encoded = try #require(envelope.encode()) + let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) + return try NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: envelope.recipientTag.hexEncodedString(), + expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000), + senderIdentity: identity + ) + } + + // MARK: - Sender role + + @Test func depositSealsAndPublishesOnce() throws { + let fixture = Fixture() + let recipientKey = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: recipientKey) + let messageID = UUID().uuidString + + fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) + fixture.service.depositDrop(content: "hello", messageID: messageID, recipientNoiseKey: recipientKey) + + #expect(fixture.sealRequests.count == 1) + #expect(fixture.publishedEvents.count == 1) + let event = try #require(fixture.publishedEvents.first) + #expect(event.kind == NostrProtocol.EventKind.courierDrop.rawValue) + #expect(event.isValidSignature()) + #expect(event.tags.contains { $0.count >= 2 && $0[0] == "x" && $0[1] == fixture.sealResult?.recipientTag.hexEncodedString() }) + #expect(event.tags.contains { $0.count >= 2 && $0[0] == "expiration" }) + } + + @Test func depositRequiresBridgeToggle() { + let fixture = Fixture() + fixture.bridgeOn = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + fixture.service.depositDrop(content: "hi", messageID: UUID().uuidString, recipientNoiseKey: key) + + #expect(fixture.publishedEvents.isEmpty) + #expect(fixture.sealRequests.isEmpty) + } + + @Test func depositQueuesWithoutRelaysAndFlushesOnReconnect() { + let fixture = Fixture() + fixture.relaysConnected = false + let key = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: key) + + fixture.service.depositDrop(content: "later", messageID: UUID().uuidString, recipientNoiseKey: key) + #expect(fixture.publishedEvents.isEmpty) + #expect(fixture.service.pendingDrops.count == 1) + + fixture.relaysConnected = true + fixture.service.flushPendingDrops() + #expect(fixture.publishedEvents.count == 1) + #expect(fixture.service.pendingDrops.isEmpty) + } + + @Test func distinctDropsUseDistinctThrowawayKeys() { + let fixture = Fixture() + let keyA = Fixture.randomKey() + let keyB = Fixture.randomKey() + fixture.sealResult = makeEnvelope(recipientKey: keyA) + fixture.service.depositDrop(content: "a", messageID: UUID().uuidString, recipientNoiseKey: keyA) + fixture.sealResult = makeEnvelope(recipientKey: keyB) + fixture.service.depositDrop(content: "b", messageID: UUID().uuidString, recipientNoiseKey: keyB) + + #expect(fixture.publishedEvents.count == 2) + #expect(fixture.publishedEvents[0].pubkey != fixture.publishedEvents[1].pubkey) + } + + @Test func bridgingPublishesHeldEnvelopesWithCooldown() { + let fixture = Fixture() + fixture.held = [makeEnvelope(recipientKey: Fixture.randomKey())] + + fixture.service.publishHeldEnvelopes() + + #expect(fixture.publishedEvents.count == 1) + #expect(fixture.heldCooldowns == [BridgeCourierService.Limits.heldEnvelopePublishCooldown]) + } + + // MARK: - Subscription management + + @Test func refreshSubscribesOwnCandidateTags() throws { + let fixture = Fixture() + fixture.service.refresh() + + let tags = try #require(fixture.openedSubscriptions.last) + let myKey = try #require(fixture.myKey) + let expected = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).map { $0.hexEncodedString() }) + #expect(Set(tags) == expected) + #expect(tags.count == 3) // adjacent UTC days + } + + @Test func refreshAlsoWatchesLocalVerifiedPeers() throws { + let fixture = Fixture() + let peerKey = Fixture.randomKey() + fixture.localPeers = [(PeerID(str: "aabbccdd00112233"), peerKey)] + + fixture.service.refresh() + + let tags = try #require(fixture.openedSubscriptions.last) + #expect(tags.count == 6) // 3 own + 3 watched + } + + @Test func refreshClosesSubscriptionWhenBridgeOff() { + let fixture = Fixture() + fixture.service.refresh() + #expect(fixture.openedSubscriptions.count == 1) + + fixture.bridgeOn = false + fixture.service.refresh() + #expect(fixture.closedSubscriptions == 1) + } + + // MARK: - Inbound drops + + @Test func dropForUsIsOpened() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: myKey) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.openedEnvelopes.count == 1) + #expect(fixture.delivered.isEmpty) + } + + @Test func duplicateDropEventOpensOnce() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let event = try makeDropEvent(for: makeEnvelope(recipientKey: myKey)) + + fixture.service.handleDropEvent(event) + fixture.service.handleDropEvent(event) + + #expect(fixture.openedEnvelopes.count == 1) + } + + @Test func dropForWatchedLocalPeerIsDelivered() throws { + let fixture = Fixture() + let peerKey = Fixture.randomKey() + let peer = PeerID(str: "aabbccdd00112233") + fixture.localPeers = [(peer, peerKey)] + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: peerKey) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.delivered.count == 1) + #expect(fixture.delivered.first?.peer == peer) + #expect(fixture.openedEnvelopes.isEmpty) + } + + @Test func dropForStrangerIsIgnored() throws { + let fixture = Fixture() + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) + + fixture.service.handleDropEvent(try makeDropEvent(for: envelope)) + + #expect(fixture.openedEnvelopes.isEmpty) + #expect(fixture.delivered.isEmpty) + } + + @Test func mislabeledDropTagIsRejected() throws { + // The event's filterable #x tag must match the envelope's own tag. + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let envelope = makeEnvelope(recipientKey: Fixture.randomKey()) + let encoded = try #require(envelope.encode()) + let identity = try #require(BridgeCourierService.makeThrowawayIdentity()) + let mislabeled = try NostrProtocol.createCourierDropEvent( + envelope: encoded, + recipientTagHex: CourierEnvelope.recipientTag( + noiseStaticKey: myKey, + epochDay: CourierEnvelope.epochDay(for: Date()) + ).hexEncodedString(), // labeled for us, addressed to a stranger + expiresAt: Date().addingTimeInterval(3600), + senderIdentity: identity + ) + + fixture.service.handleDropEvent(mislabeled) + + #expect(fixture.openedEnvelopes.isEmpty) + #expect(fixture.delivered.isEmpty) + } + + @Test func expiredDropIsIgnored() throws { + let fixture = Fixture() + let myKey = try #require(fixture.myKey) + fixture.service.refresh() + let expired = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag(noiseStaticKey: myKey, epochDay: CourierEnvelope.epochDay(for: Date())), + expiry: UInt64((Date().timeIntervalSince1970 - 60) * 1000), + ciphertext: Data(repeating: 1, count: 32), + copies: 1 + ) + + fixture.service.handleDropEvent(try makeDropEvent(for: expired)) + + #expect(fixture.openedEnvelopes.isEmpty) + } +} diff --git a/bitchatTests/Services/BridgeServiceTests.swift b/bitchatTests/Services/BridgeServiceTests.swift new file mode 100644 index 00000000..0dc62552 --- /dev/null +++ b/bitchatTests/Services/BridgeServiceTests.swift @@ -0,0 +1,594 @@ +// +// BridgeServiceTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@Suite("Mesh bridge policy") +@MainActor +struct BridgeServiceTests { + private static let cell = "u4pruy" + + /// Closure-injected harness around `BridgeService` recording every side + /// effect, with a controllable clock, location, and connectivity. + @MainActor + private final class Fixture { + private final class ClockBox { + var now = Date() + } + + var relaysConnected = true + var locationCell: String? = BridgeServiceTests.cell + var meshAdvertisedCell: String? + var bridgePeers: [PeerID] = [] + var sendSucceeds = true + var locallySeenMessageIDs: Set = [] + var nickname = "tester" + + private(set) var published: [(event: NostrEvent, cell: String)] = [] + + /// Published chat messages only — the fixture's own presence + /// heartbeats (kind 20001, sent on enable) are filtered out. + var publishedMessages: [(event: NostrEvent, cell: String)] { + published.filter { $0.event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue } + } + private(set) var broadcasts: [Data] = [] + private(set) var injected: [BridgeService.InboundBridgeMessage] = [] + private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = [] + private(set) var openedSubscriptions: [[String]] = [] + private(set) var closedSubscriptions = 0 + private(set) var enabledChanges: [Bool] = [] + private(set) var locationFixRequests = 0 + private(set) var cellChanges: [String?] = [] + private(set) var scheduledTimers: [(delay: TimeInterval, work: @MainActor () -> Void)] = [] + + private let clock = ClockBox() + let identity: NostrIdentity + let defaults: UserDefaults + let service: BridgeService + + init(enabled: Bool = true) { + let suite = "BridgeServiceTests-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + identity = try! NostrIdentity.generate() + let clock = clock + service = BridgeService(defaults: defaults) { clock.now } + service.publishToRelays = { [weak self] event, cell in + self?.published.append((event, cell)) + } + service.openSubscription = { [weak self] cells in + self?.openedSubscriptions.append(cells) + } + service.closeSubscription = { [weak self] in + self?.closedSubscriptions += 1 + } + service.relaysConnected = { [weak self] in self?.relaysConnected ?? false } + service.locationCell = { [weak self] in self?.locationCell } + service.requestLocationFix = { [weak self] in self?.locationFixRequests += 1 } + service.meshAdvertisedCell = { [weak self] in self?.meshAdvertisedCell } + service.sendToBridgePeer = { [weak self] payload, peer in + guard let self, self.sendSucceeds else { return false } + self.uplinkSends.append((payload, peer)) + return true + } + service.availableBridgePeers = { [weak self] in self?.bridgePeers ?? [] } + service.broadcastToMesh = { [weak self] payload in + self?.broadcasts.append(payload) + } + service.injectInbound = { [weak self] message in + self?.injected.append(message) + } + service.isMessageSeenLocally = { [weak self] id in + self?.locallySeenMessageIDs.contains(id) ?? false + } + service.deriveIdentity = { [weak self] _ in + guard let self else { throw NostrError.invalidEvent } + return self.identity + } + service.myNickname = { [weak self] in self?.nickname ?? "" } + service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) } + service.onActiveCellChanged = { [weak self] cell in self?.cellChanges.append(cell) } + service.scheduleTimer = { [weak self] delay, work in + self?.scheduledTimers.append((delay, work)) + } + if enabled { + service.setEnabled(true) + } + } + + func advance(_ seconds: TimeInterval) { + clock.now = clock.now.addingTimeInterval(seconds) + } + + func fireScheduledTimers() { + let due = scheduledTimers + scheduledTimers.removeAll() + for item in due { item.work() } + } + } + + // MARK: Event helpers + + private func makeRemoteEvent( + cell: String = BridgeServiceTests.cell, + content: String = "hi \(UUID().uuidString.prefix(8))", + meshMessageID: String? = UUID().uuidString + ) throws -> NostrEvent { + let identity = try NostrIdentity.generate() + return try NostrProtocol.createBridgeMeshEvent( + content: content, + cell: cell, + senderIdentity: identity, + nickname: "remote", + meshMessageID: meshMessageID + ) + } + + private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent { + try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate()) + } + + private func carrier( + _ event: NostrEvent, + direction: NostrCarrierPacket.Direction, + cell: String = BridgeServiceTests.cell + ) throws -> NostrCarrierPacket { + try #require(NostrCarrierPacket(direction: direction, geohash: cell, event: event)) + } + + // MARK: - Lifecycle & rendezvous + + @Test func enablingOpensSubscriptionForCellAndNeighbors() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + + #expect(fixture.service.activeCell == Self.cell) + let cells = try #require(fixture.openedSubscriptions.first) + #expect(cells.first == Self.cell) + #expect(cells.count == 9) // own cell + 8 neighbors + #expect(fixture.service.subscribedCells.count == 9) + } + + @Test func missingCellRequestsALocationFix() { + // Field bug: the bridge waited passively for availableChannels, + // which only flow while some other feature pumps location. Bridging + // without a cell must ask for a fix itself. + let fixture = Fixture(enabled: true) + fixture.locationCell = nil + fixture.service.refreshRendezvous() + + #expect(fixture.locationFixRequests >= 1) + #expect(fixture.service.activeCell == nil) + + // The fix lands, channels flow, and the sink re-enters here: + fixture.locationCell = Self.cell + fixture.service.refreshRendezvous() + #expect(fixture.service.activeCell == Self.cell) + } + + @Test func noLocationFallsBackToMeshAdvertisedCell() { + let fixture = Fixture(enabled: true) + fixture.locationCell = nil + fixture.meshAdvertisedCell = "u4prux" + fixture.service.refreshRendezvous() + + #expect(fixture.service.activeCell == "u4prux") + } + + @Test func disablingClosesSubscriptionAndClearsState() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.setEnabled(false) + + #expect(fixture.closedSubscriptions >= 1) + #expect(fixture.service.activeCell == nil) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func togglePersistsAcrossInstances() { + let fixture = Fixture(enabled: true) + let revived = BridgeService(defaults: fixture.defaults) + #expect(revived.isEnabled) + } + + // MARK: - Outgoing + + @Test func outgoingPublishesSignedRendezvousEventWithMeshMessageID() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let messageID = UUID().uuidString + + fixture.service.bridgeOutgoing(content: "hello hill", messageID: messageID) + + let published = try #require(fixture.published.last) + #expect(published.cell == Self.cell) + #expect(published.event.isValidSignature()) + #expect(published.event.content == "hello hill") + #expect(published.event.tags.contains(["r", Self.cell])) + #expect(published.event.tags.contains(["m", messageID])) + #expect(published.event.tags.contains(["n", "tester"])) + } + + @Test func nearbyOnlySuppressesTheBridgedCopy() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.nearbyOnly = true + + fixture.service.bridgeOutgoing(content: "just us", messageID: UUID().uuidString) + + #expect(fixture.publishedMessages.isEmpty) + #expect(fixture.uplinkSends.isEmpty) + } + + @Test func outgoingWithoutRelaysDepositsWithBridgePeer() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.relaysConnected = false + fixture.bridgePeers = [PeerID(str: "abcdef0123456789")] + + fixture.service.bridgeOutgoing(content: "no internet here", messageID: UUID().uuidString) + + #expect(fixture.publishedMessages.isEmpty) + let sent = try #require(fixture.uplinkSends.first) + let carrier = try #require(NostrCarrierPacket.decode(sent.payload)) + #expect(carrier.direction == .toBridge) + #expect(carrier.geohash == Self.cell) + } + + @Test func ownRelayBackfilledEventIsIgnoredAfterRestart() throws { + // Field bug: a relaunch wipes the published-ID cache, and relay + // backfill then re-delivered the device's own pre-restart events as + // "bridged". Self-recognition by the deterministic rendezvous pubkey + // must catch them with no cache state at all. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let ownOldEvent = try NostrProtocol.createBridgeMeshEvent( + content: "sent before the restart", + cell: Self.cell, + senderIdentity: fixture.identity, // == deriveIdentity(cell) + nickname: "tester", + meshMessageID: UUID().uuidString + ) + + fixture.service.handleRendezvousEvent(ownOldEvent) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func ownEventComingBackFromSubscriptionIsIgnored() { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.bridgeOutgoing(content: "echo me", messageID: UUID().uuidString) + let ownEvent = fixture.published[0].event + + fixture.service.handleRendezvousEvent(ownEvent) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + // MARK: - Subscription ingress + + @Test func remoteMessageInjectsAndDownlinks() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.count == 1) + #expect(fixture.injected.first?.content == event.content) + #expect(fixture.service.bridgedPeerCount == 1) + // Serving duty: after the jitter holdoff, the remote event rides out + // as a fromBridge broadcast — one switch, no gateway toggle. + #expect(fixture.broadcasts.isEmpty) + fixture.fireScheduledTimers() + let broadcast = try #require(fixture.broadcasts.first) + let carrier = try #require(NostrCarrierPacket.decode(broadcast)) + #expect(carrier.direction == .fromBridge) + } + + @Test func jitterHoldoffSuppressesAlreadyBroadcastEvents() throws { + // Two gateways, one island: while our drain waits out the jitter, + // the other gateway's fromBridge broadcast arrives — ours must yield. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleRendezvousEvent(event) // queued behind jitter + fixture.service.handleMeshCarrier( + try carrier(event, direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: false + ) + fixture.fireScheduledTimers() + + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.injected.count == 1) // rendered once, either path + } + + @Test func neighborCellEventIsAccepted() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let neighbor = try #require(Geohash.neighbors(of: Self.cell).first) + let event = try makeRemoteEvent(cell: neighbor) + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.count == 1) + } + + @Test func outOfRingCellEventIsRejected() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent(cell: "9q8yyk") + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + } + + @Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let messageID = UUID().uuidString + fixture.locallySeenMessageIDs = [messageID] + let event = try makeRemoteEvent(meshMessageID: messageID) + + fixture.service.handleRendezvousEvent(event) + + // The island already heard this over radio: no duplicate render, no + // wasted airtime — but the sender still counts as a (local) + // participant, never a bridged one. + #expect(fixture.injected.isEmpty) + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func duplicateSubscriptionEventInjectsOnce() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleRendezvousEvent(event) + fixture.service.handleRendezvousEvent(event) + fixture.fireScheduledTimers() + + #expect(fixture.injected.count == 1) + #expect(fixture.broadcasts.count == 1) + } + + @Test func presenceCountsParticipantWithoutInjection() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + + fixture.service.handleRendezvousEvent(try makePresenceEvent()) + + #expect(fixture.injected.isEmpty) + #expect(fixture.service.bridgedPeerCount == 1) + } + + @Test func staleParticipantsAgeOutOfTheCount() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.service.handleRendezvousEvent(try makePresenceEvent()) + #expect(fixture.service.bridgedPeerCount == 1) + + fixture.advance(BridgeService.Limits.participantFreshnessSeconds + 1) + fixture.service.publishPresence() // any activity recomputes via prune path + fixture.fireScheduledTimers() + + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func staleEventIsRejected() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + fixture.advance(BridgeService.Limits.maxEventAgeSeconds + 60) + + fixture.service.handleRendezvousEvent(event) + + #expect(fixture.injected.isEmpty) + } + + // MARK: - Downlink budget + + @Test func downlinkRespectsPerMinuteBudgetAndDrainsLater() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + + for _ in 0..<(BridgeService.Limits.downlinkEventsPerMinute + 5) { + fixture.service.handleRendezvousEvent(try makeRemoteEvent()) + } + fixture.fireScheduledTimers() // jitter holdoff elapses + + #expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute) + // Window frees: the re-armed timer drains the backlog. + fixture.advance(61) + fixture.fireScheduledTimers() + #expect(fixture.broadcasts.count == BridgeService.Limits.downlinkEventsPerMinute + 5) + } + + // MARK: - Mesh carrier ingress (receiver role) + + @Test func fromBridgeBroadcastInjectsForMeshOnlyReceiver() throws { + // Reception is not gated on the toggle: passive radio. + let fixture = Fixture(enabled: false) + let event = try makeRemoteEvent() + + fixture.service.handleMeshCarrier( + try carrier(event, direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: false + ) + + #expect(fixture.injected.count == 1) + #expect(fixture.service.bridgedPeerCount == 1) + } + + @Test func fromBridgeBroadcastDedupsAcrossMeshPaths() throws { + let fixture = Fixture(enabled: false) + let event = try makeRemoteEvent() + let packet = try carrier(event, direction: .fromBridge) + let peer = PeerID(str: "aabbccdd00112233") + + fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false) + fixture.service.handleMeshCarrier(packet, from: peer, directedToUs: false) + + #expect(fixture.injected.count == 1) + } + + @Test func meshCarriedEventIsNeverRebroadcast() throws { + // Loop rule 1: a second gateway hearing a fromBridge broadcast must + // not downlink the same event when its own subscription delivers it. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleMeshCarrier( + try carrier(event, direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: false + ) + fixture.service.handleRendezvousEvent(event) + fixture.fireScheduledTimers() + + #expect(fixture.broadcasts.isEmpty) + #expect(fixture.injected.count == 1) + } + + @Test func directedFromBridgeIsMalformedAndDropped() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + + fixture.service.handleMeshCarrier( + try carrier(makeRemoteEvent(), direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: true + ) + + #expect(fixture.injected.isEmpty) + } + + @Test func tamperedCarrierEventIsRejected() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + let dict: [String: Any] = [ + "id": event.id, + "pubkey": event.pubkey, + "created_at": event.created_at, + "kind": event.kind, + "tags": event.tags, + "content": event.content + " (tampered)", + "sig": event.sig ?? "", + ] + let forged = try NostrEvent(from: dict) + + fixture.service.handleMeshCarrier( + try carrier(forged, direction: .fromBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: false + ) + + #expect(fixture.injected.isEmpty) + } + + // MARK: - Uplink deposits (gateway role) + + @Test func validDepositIsPublishedWhenRelaysUp() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleMeshCarrier( + try carrier(event, direction: .toBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: true + ) + + #expect(fixture.published.contains { $0.event.id == event.id }) + // Deposits never inject: the depositor's radio broadcast already + // carried the message to this island. + #expect(fixture.injected.isEmpty) + } + + @Test func depositQueuesWhileRelaysDownAndFlushesOnReconnect() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + fixture.relaysConnected = false + let event = try makeRemoteEvent() + + fixture.service.handleMeshCarrier( + try carrier(event, direction: .toBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: true + ) + #expect(fixture.publishedMessages.isEmpty) + #expect(fixture.service.queuedUplinks.count == 1) + + fixture.relaysConnected = true + fixture.service.flushQueuedUplinks() + #expect(fixture.published.contains { $0.event.id == event.id }) + } + + @Test func depositRequiresBridgeToggle() throws { + let fixture = Fixture(enabled: false) + + fixture.service.handleMeshCarrier( + try carrier(makeRemoteEvent(), direction: .toBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: true + ) + + #expect(fixture.publishedMessages.isEmpty) + #expect(fixture.service.queuedUplinks.isEmpty) + } + + @Test func depositRateLimitBoundsPerDepositor() throws { + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let depositor = PeerID(str: "aabbccdd00112233") + + for _ in 0..<(BridgeService.Limits.uplinkEventsPerMinutePerDepositor + 4) { + fixture.service.handleMeshCarrier( + try carrier(makeRemoteEvent(), direction: .toBridge), + from: depositor, + directedToUs: true + ) + } + + #expect(fixture.publishedMessages.count == BridgeService.Limits.uplinkEventsPerMinutePerDepositor) + } + + @Test func depositedEventIsNeverDownlinkedBack() throws { + // Loop rule 2: our own relay subscription redelivering an event we + // uplinked must not burn airtime broadcasting it back. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let event = try makeRemoteEvent() + + fixture.service.handleMeshCarrier( + try carrier(event, direction: .toBridge), + from: PeerID(str: "aabbccdd00112233"), + directedToUs: true + ) + fixture.service.handleRendezvousEvent(event) + fixture.fireScheduledTimers() + + #expect(fixture.broadcasts.isEmpty) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index b33c905b..e86b52e1 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -498,13 +498,15 @@ struct ViewSmokeTests { description: "app_info.features.encryption.description" ) + // AppInfoView's settings pane reads LocationChannelsModel from the + // environment, so it can only render mounted with one installed. let appInfo = AppInfoView() + .environmentObject(LocationChannelsModel(manager: makeSmokeLocationManager())) let header = SectionHeader("app_info.features.title") let featureRow = FeatureRow(info: feature) let paymentCashu = PaymentChipView(paymentType: .cashu("cashuA_test-token")) let paymentLightning = PaymentChipView(paymentType: .lightning("lightning:lnbc1test")) - _ = appInfo.body _ = header.body _ = featureRow.body _ = paymentCashu.body diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift index 80ebf506..01cc0769 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatMessage.swift @@ -30,6 +30,9 @@ public final class BitchatMessage: Codable { public let senderPeerID: PeerID? public let mentions: [String]? // Array of mentioned nicknames public var deliveryStatus: DeliveryStatus? // Delivery tracking + /// True when this message reached us across a mesh bridge (signed by its + /// author for an internet rendezvous) rather than over local radio. + public let isBridged: Bool // Cached formatted text (not included in Codable) private var _cachedFormattedText: [String: AttributedString] = [:] @@ -46,8 +49,26 @@ public final class BitchatMessage: Codable { enum CodingKeys: String, CodingKey { case id, sender, content, timestamp, isRelay, originalSender case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus + case isBridged } - + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + sender = try container.decode(String.self, forKey: .sender) + content = try container.decode(String.self, forKey: .content) + timestamp = try container.decode(Date.self, forKey: .timestamp) + isRelay = try container.decode(Bool.self, forKey: .isRelay) + originalSender = try container.decodeIfPresent(String.self, forKey: .originalSender) + isPrivate = try container.decode(Bool.self, forKey: .isPrivate) + recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname) + senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID) + mentions = try container.decodeIfPresent([String].self, forKey: .mentions) + deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus) + // Absent in archives written before bridging existed. + isBridged = try container.decodeIfPresent(Bool.self, forKey: .isBridged) ?? false + } + public init( id: String? = nil, sender: String, @@ -59,7 +80,8 @@ public final class BitchatMessage: Codable { recipientNickname: String? = nil, senderPeerID: PeerID? = nil, mentions: [String]? = nil, - deliveryStatus: DeliveryStatus? = nil + deliveryStatus: DeliveryStatus? = nil, + isBridged: Bool = false ) { self.id = id ?? UUID().uuidString self.sender = sender @@ -72,6 +94,7 @@ public final class BitchatMessage: Codable { self.senderPeerID = senderPeerID self.mentions = mentions self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) + self.isBridged = isBridged } } @@ -89,7 +112,8 @@ extension BitchatMessage: Equatable { lhs.recipientNickname == rhs.recipientNickname && lhs.senderPeerID == rhs.senderPeerID && lhs.mentions == rhs.mentions && - lhs.deliveryStatus == rhs.deliveryStatus + lhs.deliveryStatus == rhs.deliveryStatus && + lhs.isBridged == rhs.isBridged } } @@ -121,6 +145,7 @@ extension BitchatMessage { if recipientNickname != nil { flags |= 0x08 } if senderPeerID != nil { flags |= 0x10 } if mentions != nil && !mentions!.isEmpty { flags |= 0x20 } + if isBridged { flags |= 0x40 } data.append(flags) @@ -213,6 +238,7 @@ extension BitchatMessage { let hasRecipientNickname = (flags & 0x08) != 0 let hasSenderPeerID = (flags & 0x10) != 0 let hasMentions = (flags & 0x20) != 0 + let isBridged = (flags & 0x40) != 0 // Timestamp guard offset + 8 <= dataCopy.count else { @@ -321,7 +347,8 @@ extension BitchatMessage { isPrivate: isPrivate, recipientNickname: recipientNickname, senderPeerID: senderPeerID, - mentions: mentions + mentions: mentions, + isBridged: isBridged ) } } diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index 94858d9b..167f9884 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -20,6 +20,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { public static let board = PeerCapabilities(rawValue: 1 << 4) public static let vouch = PeerCapabilities(rawValue: 1 << 5) public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6) + /// Bridges the local mesh channel to the geohash-cell rendezvous on Nostr + /// (uplink/downlink carriers for mesh-only peers). Advertised alongside + /// a `bridgeGeohash` TLV carrying the rendezvous cell. + public static let bridge = PeerCapabilities(rawValue: 1 << 7) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift index f22fac18..a9a18973 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift @@ -37,6 +37,10 @@ public struct PeerID: Equatable, Hashable, Sendable { /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a /// private group (16-byte group ID). Never routed to a single peer. case group = "group_" + /// `"bridge:"` (+ 16 characters hex) — a sender reached across a mesh + /// bridge, identified by their rendezvous Nostr pubkey. Not a + /// routable mesh peer. + case bridge = "bridge:" } public let prefix: Prefix @@ -67,6 +71,12 @@ public extension PeerID { self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength)) } + /// Convenience init to create a bridged-sender PeerID by appending + /// `"bridge:"` to the first 16 characters of the rendezvous Nostr pubkey. + init(bridge pubKey: String) { + self.init(prefix: .bridge, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength)) + } + /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts init(str: any StringProtocol) { if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) { @@ -167,6 +177,11 @@ public extension PeerID { prefix == .group } + /// Returns true if `id` starts with "`bridge:`" + var isBridge: Bool { + prefix == .bridge + } + func toPercentEncoded() -> String { id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id } diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift new file mode 100644 index 00000000..c0c8910f --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/BitchatMessageBridgedFlagTests.swift @@ -0,0 +1,57 @@ +// +// BitchatMessageBridgedFlagTests.swift +// BitFoundationTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import BitFoundation + +@Suite("BitchatMessage bridged flag") +struct BitchatMessageBridgedFlagTests { + @Test func bridgedFlagSurvivesBinaryRoundTrip() throws { + let message = BitchatMessage( + sender: "far-friend", + content: "hello from across the hill", + timestamp: Date(), + isRelay: false, + senderPeerID: PeerID(bridge: "deadbeefcafe0123deadbeefcafe0123"), + isBridged: true + ) + let binary = try #require(message.toBinaryPayload()) + let decoded = try #require(BitchatMessage(binary)) + #expect(decoded.isBridged) + #expect(decoded.senderPeerID?.isBridge == true) + } + + @Test func plainMessageStaysUnbridgedThroughBinary() throws { + let plain = BitchatMessage( + sender: "neighbor", + content: "radio only", + timestamp: Date(), + isRelay: false + ) + let binary = try #require(plain.toBinaryPayload()) + let decoded = try #require(BitchatMessage(binary)) + #expect(!decoded.isBridged) + } + + @Test func legacyBinaryWithoutBridgedBitDecodesUnbridged() throws { + // A pre-bridge encoder never sets flag bit 0x40; decoding such a + // payload must default to unbridged. + let message = BitchatMessage( + sender: "old", + content: "hi", + timestamp: Date(), + isRelay: false, + isBridged: true + ) + var binary = try #require(message.toBinaryPayload()) + binary[0] &= ~UInt8(0x40) // strip the bridged bit like an old client + let decoded = try #require(BitchatMessage(binary)) + #expect(!decoded.isBridged) + } +}