From 74b8a47d024b6e61277dbd2eb349f649a30dd59b Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:24:44 +0200 Subject: [PATCH] Empty-mesh liveliness: nearby conversations, echoes, wave, dead drops, radar (#1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Empty-mesh liveliness: nearby conversations, echoes, wave action, dead drops, radar The empty mesh timeline was a dead end: a grey zero and "nobody in range yet". This turns it into a live surface and gives the app pull when the mesh wakes up: - Nearest conversation: background geohash sampling now tracks actual chat messages (not just presence) per regional channel; the empty state surfaces the busiest nearby conversation with a preview, one tap to join (GeohashChatActivityTracker, fed from GeoPresenceTracker). - Echoes: the carried 6h store-and-forward window renders as dimmed "heard here earlier" rows at launch (new Transport collectArchivedPublicMessages -> GossipSyncManager snapshot, decoded with signature-derived nicknames; content-identity dedup guards against re-synced duplicates). - Wave: the "bitchatters nearby" notification gains a "wave" quick action that broadcasts a mesh 👋 straight from the notification, even backgrounded (first UNNotificationCategory in the app). - Dead drops: /drop pins a note to the current building geohash as a kind-1 location note with a 24h NIP-40 expiry; expired notes are now dropped client-side at ingest; the notices sheet shows "fades in Xh"; a "location notes" toggle plus location-permission controls live in app info (also fixes the duplicated Voice section). - Radar: an ambient sonar animation shows the radio scanning, with a privacy-safe daily tally ("N devices passed within range today" via salted per-day hashes) and a "notes left here" hint that opens the notices geo tab. All new user-facing strings ship in all 29 locales. 1403 tests green, including new suites for the activity tracker, sightings tally, and note expiry/drop publishing. Co-Authored-By: Claude Fable 5 * Review round: app-info polish, urgent/expiry parity, centered radar, pin fill, Codex P2s - App info: LOCATION header uppercased like sibling sections; location notes and live voice descriptions shortened (29 locales); redundant "location access granted" line removed. - Notices parity: urgent + expiry controls now show on the geo tab too; the bridged Nostr note carries ["t","urgent"] and NIP-40 so relay-side readers see both; urgent parsed back from incoming notes. - Radar moved from the top of the empty state to the center of the chat area, below the help text (empty state fills the visible height). - Header pin fills whenever the scope has notices (was: only unseen), and Nostr-only nearby notes now light it too. - Codex P2 fixes: notification completion deferred until the wave action is handled (background suspension dropped the send); NIP-40 notes now prune on a timer when they expire while displayed; the location-notes kill switch retargets the nearby-notes counter immediately. Co-Authored-By: Claude Fable 5 * Hide macOS segmented picker's built-in label in notices composer (duplicate 'expires in') Co-Authored-By: Claude Fable 5 * Geo notes: permanent (∞) expiry default, urgent stays mesh-only - Geo expiry picker gains ∞ as the default: a permanent note posts as a pure relay note (no NIP-40 tag, no mesh-board copy — a board copy must fade within days, contradicting the ∞ the user picked). 1/3/7d keep the board + bridged-note path with NIP-40. - The notes manager is now owned by the notices sheet (not the list) so the composer local-echoes ∞ notes into the list; it revives via refresh() after a tab-switch cancel, and its expiry-prune timer survives cancel (weak self, dies with the instance). - Urgent toggle returns to mesh-only per review — notes are ambient; the read-side urgent-tag parse stays so tagged notes still render. - macOS: hide the segmented picker's built-in label (duplicate "expires in"). Co-Authored-By: Claude Fable 5 * Clearing the mesh timeline dismisses echoes for good; tighter divider copy Triple-tap /clear emptied the timeline but the next launch re-seeded "heard here earlier" from the persisted archive. A MeshEchoSettings watermark now records the clear; only messages heard after it come back (the archive itself still carries everything for peers' sync). The echo dedup keys reset with it, and panic wipe drops the watermark. Divider copy tightened to "heard here earlier · last 6h" (29 locales). Co-Authored-By: Claude Fable 5 * Echoes visual polish: tinted history block, radar-captioned tally, ambient footer Device-test feedback on the echoes screen: - Archived echoes now sit on a subtle tinted background (secondary at 8%) in addition to the dim, so "heard here earlier" reads as one distinct block; the divider carries the echo ID prefix to join it. - "N devices passed within range today" moves out of the narration lines to sit centered under the radar as its caption. - When the timeline holds only echoes/system lines, a compact ambient footer (small radar + tally + live hints) renders below the history instead of the whole ambient layer vanishing. Co-Authored-By: Claude Fable 5 * Notes strip persists above the mesh chat; leaner empty-state narration - The 📍 "notes left here" line was empty-state-only, so starting a conversation hid it. It is now a tappable strip pinned above the mesh timeline whenever unexpired notes exist at this place (opens the notices geo tab); the nearby-notes counter runs for the whole mesh timeline, not just the empty state. - Empty state narration drops "nobody in range yet..." (the radar and the sightings caption already say it) and the nearby-conversation hint moves below the help line instead of splitting the narration. Co-Authored-By: Claude Fable 5 * Radar means searching: hide the sweep once mesh peers are connected or reachable Co-Authored-By: Claude Fable 5 --------- Co-authored-by: jack Co-authored-by: Claude Fable 5 --- bitchat/App/AppChromeModel.swift | 9 + bitchat/App/AppRuntime.swift | 11 +- bitchat/App/NearbyNotesCounter.swift | 89 + bitchat/BitchatApp.swift | 12 +- bitchat/Localizable.xcstrings | 2938 ++++++++++++++++- bitchat/Models/CommandInfo.swift | 6 +- bitchat/Nostr/NostrProtocol.swift | 6 +- bitchat/Services/BLE/BLEService.swift | 50 + bitchat/Services/Board/BoardManager.swift | 11 +- bitchat/Services/Board/UnifiedNotices.swift | 9 +- bitchat/Services/CommandProcessor.swift | 29 + .../Services/GeohashChatActivityTracker.swift | 122 + bitchat/Services/LocationNotesManager.swift | 119 +- bitchat/Services/LocationNotesSettings.swift | 29 + bitchat/Services/MeshEchoSettings.swift | 27 + bitchat/Services/MeshSightingsTracker.swift | 106 + bitchat/Services/NotificationService.swift | 56 +- bitchat/Services/Transport.swift | 30 + bitchat/Services/TransportConfig.swift | 8 + bitchat/Sync/GossipSyncManager.swift | 13 + .../ViewModels/ChatOutgoingCoordinator.swift | 7 + .../ViewModels/ChatPeerListCoordinator.swift | 10 + .../ChatPublicConversationCoordinator.swift | 35 + bitchat/ViewModels/ChatViewModel.swift | 12 + .../ChatViewModelBootstrapper.swift | 54 + bitchat/ViewModels/GeoPresenceTracker.swift | 23 + bitchat/Views/AppInfoView.swift | 58 +- .../Views/Components/MeshEmptyStateView.swift | 180 + bitchat/Views/Components/MeshRadarView.swift | 68 + bitchat/Views/ContentHeaderView.swift | 29 +- bitchat/Views/MessageListView.swift | 105 +- bitchat/Views/NoticesView.swift | 149 +- .../ChatPeerListCoordinatorContextTests.swift | 7 + bitchatTests/LocationNotesManagerTests.swift | 120 + .../GeohashChatActivityTrackerTests.swift | 113 + .../Services/MeshSightingsTrackerTests.swift | 81 + bitchatTests/ViewSmokeTests.swift | 2 +- 37 files changed, 4633 insertions(+), 100 deletions(-) create mode 100644 bitchat/App/NearbyNotesCounter.swift create mode 100644 bitchat/Services/GeohashChatActivityTracker.swift create mode 100644 bitchat/Services/LocationNotesSettings.swift create mode 100644 bitchat/Services/MeshEchoSettings.swift create mode 100644 bitchat/Services/MeshSightingsTracker.swift create mode 100644 bitchat/Views/Components/MeshEmptyStateView.swift create mode 100644 bitchat/Views/Components/MeshRadarView.swift create mode 100644 bitchatTests/Services/GeohashChatActivityTrackerTests.swift create mode 100644 bitchatTests/Services/MeshSightingsTrackerTests.swift diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift index a920c128..9db9e51e 100644 --- a/bitchat/App/AppChromeModel.swift +++ b/bitchat/App/AppChromeModel.swift @@ -10,6 +10,10 @@ final class AppChromeModel: ObservableObject { @Published var showingFingerprintFor: PeerID? @Published var isAppInfoPresented = false @Published var isLocationChannelsSheetPresented = false + @Published var isNoticesSheetPresented = false + /// When the sheet is opened for "notes left here" (empty mesh timeline), + /// it should land on the geo tab instead of the channel-derived default. + @Published var noticesSheetPrefersGeoTab = false @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown @@ -62,6 +66,11 @@ final class AppChromeModel: ObservableObject { isAppInfoPresented = true } + func presentNotices(geoTab: Bool = false) { + noticesSheetPrefersGeoTab = geoTab + isNoticesSheetPresented = true + } + /// Builds the mesh topology map model from the transport's gossiped /// graph plus the live nickname table. Unknown nodes (heard about via a /// neighbor claim but never announced to us) fall back to a short ID. diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift index 27d0d993..81f2995b 100644 --- a/bitchat/App/AppRuntime.swift +++ b/bitchat/App/AppRuntime.swift @@ -217,7 +217,16 @@ final class AppRuntime: ObservableObject { chatViewModel.applicationWillTerminate() } - func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) { + func handleNotificationResponse( + identifier: String, + actionIdentifier: String = UNNotificationDefaultActionIdentifier, + userInfo: [AnyHashable: Any] + ) { + if actionIdentifier == NotificationService.waveActionID { + chatViewModel.sendMeshWave() + return + } + if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { record(.notificationOpened(peerID: peerID)) chatViewModel.startPrivateChat(with: peerID) diff --git a/bitchat/App/NearbyNotesCounter.swift b/bitchat/App/NearbyNotesCounter.swift new file mode 100644 index 00000000..3c1f630a --- /dev/null +++ b/bitchat/App/NearbyNotesCounter.swift @@ -0,0 +1,89 @@ +// +// NearbyNotesCounter.swift +// bitchat +// +// Counts unexpired location notes left at the user's current building-level +// geohash so the empty mesh timeline can say "📍 3 notes left here". Only +// subscribes while a view holds it active, and only when location notes are +// enabled and location permission is already granted (it never prompts). +// This is free and unencumbered software released into the public domain. +// + +import Combine +import Foundation + +@MainActor +final class NearbyNotesCounter: ObservableObject { + static let shared = NearbyNotesCounter() + + @Published private(set) var noteCount = 0 + + private var manager: LocationNotesManager? + private var managerCancellable: AnyCancellable? + private var channelsCancellable: AnyCancellable? + private var settingCancellable: AnyCancellable? + private var activeHolders = 0 + private let locationManager: LocationChannelManager + + init(locationManager: LocationChannelManager = .shared) { + self.locationManager = locationManager + } + + /// Begins (or keeps) the notes subscription for the current building + /// geohash. Balanced by `deactivate()`; ref-counted so multiple views can + /// hold it. + func activate() { + activeHolders += 1 + guard activeHolders == 1 else { return } + channelsCancellable = locationManager.$availableChannels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.retarget() } + // The app-info kill switch must take effect immediately, not on the + // next location change or remount. + settingCancellable = NotificationCenter.default + .publisher(for: LocationNotesSettings.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.retarget() } + retarget() + } + + func deactivate() { + activeHolders = max(0, activeHolders - 1) + guard activeHolders == 0 else { return } + channelsCancellable = nil + settingCancellable = nil + managerCancellable = nil + manager?.cancel() + manager = nil + noteCount = 0 + } + + private func retarget() { + guard activeHolders > 0, + LocationNotesSettings.enabled, + locationManager.permissionState == .authorized, + let geohash = locationManager.availableChannels + .first(where: { $0.level == .building })?.geohash + else { + managerCancellable = nil + manager?.cancel() + manager = nil + noteCount = 0 + return + } + + if let manager { + manager.setGeohash(geohash) + return + } + + let fresh = LocationNotesManager(geohash: geohash) + manager = fresh + managerCancellable = fresh.$notes + .receive(on: DispatchQueue.main) + .sink { [weak self] notes in + let now = Date() + self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count + } + } +} diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 41a01f6d..c1a39fff 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -104,12 +104,20 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier + let actionIdentifier = response.actionIdentifier let userInfo = response.notification.request.content.userInfo + // Complete only after the response is handled: for a background + // action (👋 wave) the system may suspend the app the moment the + // completion handler runs, which would drop the queued send. Task { @MainActor in - self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo) + self.runtime?.handleNotificationResponse( + identifier: identifier, + actionIdentifier: actionIdentifier, + userInfo: userInfo + ) + completionHandler() } - completionHandler() } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c0be8226..a5c93c76 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -7181,6 +7181,906 @@ } } }, + "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", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت ملاحظات في الأماكن باستخدام /drop، يقرؤها أي مارّ خلال 24 ساعة. يتطلب الموقع." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop দিয়ে জায়গায় নোট পিন করুন, 24 ঘণ্টা ধরে পাশ দিয়ে যাওয়া যে কেউ পড়তে পারবে। অবস্থান প্রয়োজন।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "hefte notizen mit /drop an orte, 24h lesbar für alle, die vorbeikommen. benötigt standort." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin notes to places with /drop, readable by anyone passing by for 24h. needs location." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija notas en lugares con /drop, legibles por cualquiera que pase durante 24h. necesita ubicación." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng mga note sa mga lugar gamit ang /drop, mababasa ng sinumang dadaan sa loob ng 24h. kailangan ng lokasyon." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épinglez des notes à des lieux avec /drop, lisibles par quiconque passe pendant 24h. nécessite la localisation." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד פתקים למקומות עם /drop, קריאים לכל מי שעובר במשך 24 שעות. דורש מיקום." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop से जगहों पर नोट पिन करें, 24 घंटे तक वहां से गुजरने वाला कोई भी पढ़ सकता है। स्थान आवश्यक।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan catatan di tempat dengan /drop, dapat dibaca siapa pun yang lewat selama 24 jam. perlu lokasi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fissa note nei luoghi con /drop, leggibili da chiunque passi per 24h. richiede la posizione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop で場所にメモをピン留め。24時間、通りかかった誰でも読めます。位置情報が必要です。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop로 장소에 메모를 고정하세요. 24시간 동안 지나가는 누구나 읽을 수 있습니다. 위치 필요." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinkan nota di tempat dengan /drop, boleh dibaca sesiapa yang lalu selama 24 jam. perlukan lokasi." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop ले ठाउँहरूमा नोट पिन गर्नुहोस्, 24 घण्टासम्म त्यहाँबाट जाने जोकोहीले पढ्न सक्छ। स्थान आवश्यक।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin notities aan plekken met /drop, 24 uur leesbaar voor iedereen die langskomt. vereist locatie." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypinaj notatki do miejsc za pomocą /drop, czytelne dla każdego przechodzącego przez 24h. wymaga lokalizacji." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe notas em locais com /drop, legíveis por quem passar durante 24h. requer localização." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixe notas em lugares com /drop, legíveis por quem passar durante 24h. precisa de localização." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепляйте заметки в местах с помощью /drop — 24 часа их сможет прочитать любой, кто проходит мимо. нужна геолокация." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "fäst anteckningar på platser med /drop, läsbara av alla som passerar i 24h. kräver plats." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop மூலம் இடங்களில் குறிப்புகளை பின் செய்யுங்கள், 24 மணி நேரம் கடந்து செல்லும் யாரும் படிக்கலாம். இருப்பிடம் தேவை." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักโน้ตไว้ตามสถานที่ด้วย /drop ใครผ่านมาก็อ่านได้ตลอด 24 ชั่วโมง ต้องใช้ตำแหน่งที่ตั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop ile yerlere not sabitle, 24 saat boyunca yoldan geçen herkes okuyabilir. konum gerekir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріплюйте нотатки в місцях за допомогою /drop — 24 години їх зможе прочитати будь-хто, хто проходить повз. потрібне місцезнаходження." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "/drop سے جگہوں پر نوٹ پن کریں، 24 گھنٹے تک وہاں سے گزرنے والا کوئی بھی پڑھ سکتا ہے۔ مقام درکار ہے۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim ghi chú vào địa điểm bằng /drop, ai đi ngang cũng đọc được trong 24h. cần vị trí." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "用 /drop 把便签钉在地点,24小时内路过的任何人都能读到。需要位置权限。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "用 /drop 將便箋釘在地點,24小時內路過的任何人都能讀到。需要位置權限。" + } + } + } + }, + "app_info.location.notes.title" : { + "comment" : "Title of the location notes toggle in app info", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ملاحظات الموقع" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "অবস্থান নোট" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "standortnotizen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "location notes" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de ubicación" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mga note sa lokasyon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "notes de lieu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתקי מיקום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान नोट" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "catatan lokasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "note di posizione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置メモ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "위치 쪽지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "nota lokasi" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थान नोटहरू" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "locatienotities" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "notatki lokalizacji" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de localização" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "notas de localização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "геозаметки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "platsanteckningar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இருப்பிடக் குறிப்புகள்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โน้ตตำแหน่งที่ตั้ง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "konum notları" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "геонотатки" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقام کے نوٹس" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghi chú vị trí" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置留言" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "位置留言" + } + } + } + }, + "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", @@ -9339,175 +10239,175 @@ "ar" : { "stringUnit" : { "state" : "translated", - "value" : "بث الرسائل الصوتية أثناء التحدث عندما يكون المستلم متاحًا؛ يتم تشغيل الصوت المباشر الوارد تلقائيًا في المحادثة المفتوحة" + "value" : "يُبث أثناء التحدث؛ الصوت المباشر الوارد يُشغَّل تلقائيًا" } }, "bn" : { "stringUnit" : { "state" : "translated", - "value" : "প্রাপক নাগালের মধ্যে থাকলে কথা বলার সাথে সাথে ভয়েস বার্তা স্ট্রিম হয়; খোলা চ্যাটে আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে" + "value" : "কথা বলার সাথে সাথে স্ট্রিম হয়; আগত লাইভ ভয়েস স্বয়ংক্রিয়ভাবে চলে" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "sprachnachrichten werden beim sprechen live übertragen, wenn der empfänger erreichbar ist; eingehende live-stimme wird im geöffneten chat automatisch abgespielt" + "value" : "überträgt live beim sprechen; eingehende live-stimme wird automatisch abgespielt" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "stream voice messages as you speak when the recipient is reachable; incoming live voice plays automatically in the open chat" + "value" : "streams as you speak; incoming live voice plays automatically" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "transmite mensajes de voz mientras hablas cuando el destinatario está disponible; la voz en vivo entrante se reproduce automáticamente en el chat abierto" + "value" : "transmite mientras hablas; la voz en vivo entrante se reproduce automáticamente" } }, "fil" : { "stringUnit" : { "state" : "translated", - "value" : "ini-stream ang mga voice message habang nagsasalita kapag maaabot ang tatanggap; awtomatikong tumutugtog ang papasok na live na boses sa bukas na chat" + "value" : "nag-i-stream habang nagsasalita ka; awtomatikong tumutugtog ang papasok na live na boses" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "diffuse les messages vocaux pendant que vous parlez lorsque le destinataire est joignable ; la voix en direct entrante est lue automatiquement dans la discussion ouverte" + "value" : "diffuse pendant que vous parlez ; la voix en direct entrante est lue automatiquement" } }, "he" : { "stringUnit" : { "state" : "translated", - "value" : "משדר הודעות קוליות בזמן שאתה מדבר כשהנמען זמין; קול חי נכנס מושמע אוטומטית בצ'אט הפתוח" + "value" : "משדר בזמן שאתה מדבר; קול חי נכנס מושמע אוטומטית" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "प्राप्तकर्ता पहुंच में होने पर बोलते समय वॉयस संदेश स्ट्रीम होते हैं; खुली चैट में आने वाली लाइव आवाज़ अपने आप चलती है" + "value" : "बोलते समय स्ट्रीम होता है; आने वाली लाइव आवाज़ अपने आप चलती है" } }, "id" : { "stringUnit" : { "state" : "translated", - "value" : "streaming pesan suara saat Anda berbicara ketika penerima terjangkau; suara langsung yang masuk diputar otomatis di obrolan yang terbuka" + "value" : "streaming saat Anda berbicara; suara langsung yang masuk diputar otomatis" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "trasmette i messaggi vocali mentre parli quando il destinatario è raggiungibile; la voce in diretta in arrivo viene riprodotta automaticamente nella chat aperta" + "value" : "trasmette mentre parli; la voce in diretta in arrivo viene riprodotta automaticamente" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "受信者に到達可能なとき、話しながら音声メッセージをライブ配信します。受信したライブ音声は開いているチャットで自動再生されます" + "value" : "話しながらライブ配信。受信したライブ音声は自動再生されます" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "상대방이 연결되어 있으면 말하는 동안 음성 메시지가 실시간 전송됩니다. 수신된 라이브 음성은 열려 있는 채팅에서 자동 재생됩니다" + "value" : "말하는 동안 실시간 전송됩니다. 수신된 라이브 음성은 자동 재생됩니다" } }, "ms" : { "stringUnit" : { "state" : "translated", - "value" : "strim mesej suara semasa anda bercakap apabila penerima boleh dihubungi; suara langsung masuk dimainkan secara automatik dalam sembang terbuka" + "value" : "strim semasa anda bercakap; suara langsung masuk dimainkan secara automatik" } }, "ne" : { "stringUnit" : { "state" : "translated", - "value" : "प्रापक पहुँचयोग्य हुँदा बोल्दै गर्दा भ्वाइस सन्देशहरू स्ट्रिम हुन्छन्; खुला च्याटमा आगमन लाइभ आवाज स्वतः बज्छ" + "value" : "बोल्दै गर्दा स्ट्रिम हुन्छ; आगमन लाइभ आवाज स्वतः बज्छ" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "streamt spraakberichten terwijl je praat wanneer de ontvanger bereikbaar is; inkomende live spraak wordt automatisch afgespeeld in de geopende chat" + "value" : "streamt terwijl je praat; inkomende live spraak wordt automatisch afgespeeld" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "przesyła wiadomości głosowe na żywo podczas mówienia, gdy odbiorca jest osiągalny; przychodzący głos na żywo odtwarza się automatycznie w otwartym czacie" + "value" : "przesyła na żywo podczas mówienia; przychodzący głos na żywo odtwarza się automatycznie" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "transmite mensagens de voz enquanto fala quando o destinatário está acessível; a voz ao vivo recebida é reproduzida automaticamente na conversa aberta" + "value" : "transmite enquanto fala; a voz ao vivo recebida é reproduzida automaticamente" } }, "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "transmite mensagens de voz enquanto você fala quando o destinatário está acessível; a voz ao vivo recebida toca automaticamente na conversa aberta" + "value" : "transmite enquanto você fala; a voz ao vivo recebida toca automaticamente" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "передаёт голосовые сообщения в реальном времени, пока вы говорите, если получатель доступен; входящий живой голос автоматически воспроизводится в открытом чате" + "value" : "передаёт, пока вы говорите; входящий живой голос воспроизводится автоматически" } }, "sv" : { "stringUnit" : { "state" : "translated", - "value" : "strömmar röstmeddelanden medan du pratar när mottagaren är nåbar; inkommande live-röst spelas upp automatiskt i den öppna chatten" + "value" : "strömmar medan du pratar; inkommande live-röst spelas upp automatiskt" } }, "ta" : { "stringUnit" : { "state" : "translated", - "value" : "பெறுநர் அணுகக்கூடியதாக இருக்கும்போது நீங்கள் பேசும்போதே குரல் செய்திகள் நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் திறந்த அரட்டையில் தானாக ஒலிக்கும்" + "value" : "நீங்கள் பேசும்போதே நேரலையாக அனுப்பப்படும்; உள்வரும் நேரலை குரல் தானாக ஒலிக்கும்" } }, "th" : { "stringUnit" : { "state" : "translated", - "value" : "สตรีมข้อความเสียงขณะพูดเมื่อติดต่อผู้รับได้ เสียงสดขาเข้าจะเล่นอัตโนมัติในแชทที่เปิดอยู่" + "value" : "สตรีมขณะพูด เสียงสดขาเข้าจะเล่นอัตโนมัติ" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "alıcı erişilebilir olduğunda sesli mesajlar siz konuşurken canlı iletilir; gelen canlı ses açık sohbette otomatik çalınır" + "value" : "siz konuşurken canlı iletilir; gelen canlı ses otomatik çalınır" } }, "uk" : { "stringUnit" : { "state" : "translated", - "value" : "передає голосові повідомлення наживо, поки ви говорите, якщо одержувач доступний; вхідний живий голос автоматично відтворюється у відкритому чаті" + "value" : "передає, поки ви говорите; вхідний живий голос відтворюється автоматично" } }, "ur" : { "stringUnit" : { "state" : "translated", - "value" : "جب وصول کنندہ قابل رسائی ہو تو بولتے وقت صوتی پیغامات لائیو نشر ہوتے ہیں؛ موصول ہونے والی لائیو آواز کھلی چیٹ میں خود بخود چلتی ہے" + "value" : "بولتے وقت لائیو نشر ہوتا ہے؛ موصول ہونے والی لائیو آواز خود بخود چلتی ہے" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "phát trực tiếp tin nhắn thoại khi bạn nói nếu người nhận trong tầm kết nối; giọng nói trực tiếp đến sẽ tự phát trong cuộc trò chuyện đang mở" + "value" : "phát trực tiếp khi bạn nói; giọng nói trực tiếp đến sẽ tự phát" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "当接收方可达时,语音消息会在你说话的同时实时传输;收到的实时语音会在打开的聊天中自动播放" + "value" : "边说边实时传输;收到的实时语音会自动播放" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "當接收方可達時,語音訊息會在你說話的同時即時傳輸;收到的即時語音會在開啟的聊天中自動播放" + "value" : "邊說邊即時傳輸;收到的即時語音會自動播放" } } } @@ -21373,6 +22273,186 @@ } } }, + "content.commands.drop" : { + "comment" : "Description of the /drop command in the command suggestions panel", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "ثبّت ملاحظة في هذا المكان لمدة 24 ساعة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এই জায়গায় 24 ঘণ্টার জন্য একটি নোট পিন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "eine notiz für 24 std. an diesem ort anheften" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin a note to this place for 24h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "fija una nota en este lugar por 24 h" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mag-pin ng note sa lugar na ito nang 24 na oras" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "épingle une note à cet endroit pendant 24 h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הצמד פתק למקום הזה ל-24 שעות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस जगह पर 24 घंटे के लिए एक नोट पिन करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "sematkan catatan di tempat ini selama 24 jam" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fissa una nota in questo luogo per 24 ore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "この場所に24時間メモをピン留め" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 장소에 24시간 동안 쪽지 고정" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pinkan nota di tempat ini selama 24 jam" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यस ठाउँमा 24 घण्टाका लागि नोट पिन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "pin een notitie op deze plek voor 24 uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "przypnij notatkę do tego miejsca na 24 godz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixar uma nota neste local por 24 h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "fixar uma nota neste lugar por 24 h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "закрепить заметку в этом месте на 24 ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "fäst en anteckning på den här platsen i 24 tim" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இந்த இடத்தில் 24 மணி நேரத்திற்கு ஒரு குறிப்பை பொருத்தவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ปักโน้ตไว้ที่นี่เป็นเวลา 24 ชม." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bu yere 24 saatliğine bir not sabitle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "закріпити нотатку в цьому місці на 24 год" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "اس جگہ پر 24 گھنٹے کے لیے ایک نوٹ پن کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ghim một ghi chú tại nơi này trong 24 giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在此地固定一条留言 24 小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在此地固定一則留言 24 小時" + } + } + } + }, "content.commands.favorite" : { "extractionState" : "manual", "localizations" : { @@ -26216,6 +27296,546 @@ } } }, + "content.echoes.divider" : { + "comment" : "System line shown above dimmed archived messages replayed on the mesh timeline at launch", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سُمع هنا سابقًا · آخر 6 ساعات" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আগে এখানে শোনা গেছে · গত ৬ ঘণ্টা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "vorhin hier gehört · letzte 6 std." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "heard here earlier · last 6h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "escuchado aquí antes · últimas 6 h" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "narinig dito kanina · huling 6 oras" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "entendu ici plus tôt · 6 dernières h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נשמע כאן קודם · 6 שעות אחרונות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "पहले यहाँ सुना गया · पिछले 6 घंटे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "terdengar di sini sebelumnya · 6 jam terakhir" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "sentito qui prima · ultime 6 ore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここで聞こえた · 過去6時間" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기서 들린 대화 · 지난 6시간" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "didengar di sini tadi · 6 jam lepas" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "पहिले यहाँ सुनिएको · पछिल्लो ६ घण्टा" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "eerder hier gehoord · afgelopen 6 u" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "słyszane tu wcześniej · ostatnie 6 godz." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvido aqui antes · últimas 6 h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "ouvido aqui antes · últimas 6 h" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "слышано здесь ранее · последние 6 ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "hört här tidigare · senaste 6 tim" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "முன்பு இங்கே கேட்டது · கடந்த 6 மணி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ได้ยินที่นี่ก่อนหน้านี้ · 6 ชม.ที่ผ่านมา" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "daha önce burada duyuldu · son 6 sa" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "почуто тут раніше · останні 6 год" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پہلے یہاں سنا گیا · گزشتہ 6 گھنٹے" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đã nghe ở đây trước đó · 6 giờ qua" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "之前在这里听到 · 最近6小时" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "之前在這裡聽到 · 最近6小時" + } + } + } + }, + "content.empty.activity_many" : { + "comment" : "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أشخاص يتحدثون في #%@ — انقر للانضمام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "লোকজন #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "leute reden in #%@ — tippen zum beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "people are talking in #%@ — tap to join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hay gente hablando en #%@ — toca para unirte" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "may mga nag-uusap sa #%@ — i-tap para sumali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "des gens parlent dans #%@ — appuyez pour rejoindre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אנשים מדברים ב-#%@ — הקש כדי להצטרף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ में लोग बात कर रहे हैं — शामिल होने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "orang-orang sedang mengobrol di #%@ — ketuk untuk bergabung" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "c'è gente che parla in #%@ — tocca per unirti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ で人々が話しています — タップして参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@에서 사람들이 이야기 중 — 탭하여 참여" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "ramai sedang berbual di #%@ — ketik untuk sertai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ मा मानिसहरू कुरा गर्दैछन् — सामेल हुन ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensen praten in #%@ — tik om mee te doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ludzie rozmawiają w #%@ — stuknij, aby dołączyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "há pessoas a falar em #%@ — toque para entrar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "tem gente conversando em #%@ — toque para entrar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "люди общаются в #%@ — нажмите, чтобы присоединиться" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "folk pratar i #%@ — tryck för att gå med" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ இல் மக்கள் பேசுகிறார்கள் — சேர தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "หลายคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ içinde insanlar konuşuyor — katılmak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "люди спілкуються в #%@ — торкніться, щоб приєднатися" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ میں لوگ بات کر رہے ہیں — شامل ہونے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mọi người đang trò chuyện trong #%@ — chạm để tham gia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大家正在 #%@ 聊天 — 点按加入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "大家正在 #%@ 聊天 — 點按加入" + } + } + } + }, + "content.empty.activity_one" : { + "comment" : "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أحدهم يتحدث في #%@ — انقر للانضمام" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কেউ #%@-এ কথা বলছে — যোগ দিতে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "jemand redet in #%@ — tippen zum beitreten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "someone is talking in #%@ — tap to join" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguien está hablando en #%@ — toca para unirte" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "may nagsasalita sa #%@ — i-tap para sumali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "quelqu'un parle dans #%@ — appuyez pour rejoindre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מישהו מדבר ב-#%@ — הקש כדי להצטרף" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ में कोई बात कर रहा है — शामिल होने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "seseorang sedang mengobrol di #%@ — ketuk untuk bergabung" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "qualcuno sta parlando in #%@ — tocca per unirti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ で誰かが話しています — タップして参加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@에서 누군가 이야기 중 — 탭하여 참여" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "seseorang sedang berbual di #%@ — ketik untuk sertai" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ मा कोही कुरा गर्दैछ — सामेल हुन ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "iemand praat in #%@ — tik om mee te doen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ktoś rozmawia w #%@ — stuknij, aby dołączyć" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguém está a falar em #%@ — toque para entrar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "alguém está conversando em #%@ — toque para entrar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "кто-то общается в #%@ — нажмите, чтобы присоединиться" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "någon pratar i #%@ — tryck för att gå med" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ இல் யாரோ பேசுகிறார்கள் — சேர தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มีคนกำลังคุยใน #%@ — แตะเพื่อเข้าร่วม" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ içinde biri konuşuyor — katılmak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "хтось спілкується в #%@ — торкніться, щоб приєднатися" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "#%@ میں کوئی بات کر رہا ہے — شامل ہونے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có người đang trò chuyện trong #%@ — chạm để tham gia" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有人正在 #%@ 聊天 — 点按加入" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有人正在 #%@ 聊天 — 點按加入" + } + } + } + }, "content.empty.location_intro" : { "comment" : "First line of an empty geohash timeline naming the channel", "extractionState" : "manual", @@ -26756,6 +28376,726 @@ } } }, + "content.empty.notes_many" : { + "comment" : "Empty mesh timeline hint counting notes left at this place", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تُركت %lld ملاحظات هنا — انقر للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখানে %lldটি নোট রাখা আছে — পড়তে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notizen hier hinterlassen — tippen zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notes left here — tap to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas dejadas aquí — toca para leer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld note ang naiwan dito — i-tap para basahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notes laissées ici — appuyez pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld פתקים הושארו כאן — הקש לקריאה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ %lld नोट छोड़े गए हैं — पढ़ने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld catatan ditinggalkan di sini — ketuk untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld note lasciate qui — tocca per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに%lld件のメモがあります — タップして読む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기 남겨진 쪽지 %lld개 — 탭하여 읽기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld nota ditinggalkan di sini — ketik untuk baca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ %lld नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notities hier achtergelaten — tik om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notatek zostawionych tutaj — stuknij, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas deixadas aqui — toque para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld notas deixadas aqui — toque para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "здесь оставлено заметок: %lld — нажмите, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld anteckningar lämnade här — tryck för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்கே %lld குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี %lld โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buraya %lld not bırakıldı — okumak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "тут залишено %lld нотаток — торкніться, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہاں %lld نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có %lld ghi chú để lại ở đây — chạm để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这里留有 %lld 条留言 — 点按阅读" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這裡留有 %lld 則留言 — 點按閱讀" + } + } + } + }, + "content.empty.notes_one" : { + "comment" : "Empty mesh timeline hint when exactly one note was left at this place", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تُركت ملاحظة واحدة هنا — انقر للقراءة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notiz hier hinterlassen — tippen zum lesen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note left here — tap to read" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota dejada aquí — toca para leer" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note ang naiwan dito — i-tap para basahin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 note laissée ici — appuyez pour lire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פתק אחד הושאר כאן — הקש לקריאה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 catatan ditinggalkan di sini — ketuk untuk membaca" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota lasciata qui — tocca per leggere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ここに1件のメモがあります — タップして読む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "여기 남겨진 쪽지 1개 — 탭하여 읽기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota ditinggalkan di sini — ketik untuk baca" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notitie hier achtergelaten — tik om te lezen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 notatka zostawiona tutaj — stuknij, aby przeczytać" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota deixada aqui — toque para ler" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 nota deixada aqui — toque para ler" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "здесь оставлена 1 заметка — нажмите, чтобы прочитать" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 anteckning lämnad här — tryck för att läsa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "buraya 1 not bırakıldı — okumak için dokun" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "тут залишено 1 нотатку — торкніться, щоб прочитати" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "có 1 ghi chú để lại ở đây — chạm để đọc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "这里留有 1 条留言 — 点按阅读" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "這裡留有 1 則留言 — 點按閱讀" + } + } + } + }, + "content.empty.sightings_many" : { + "comment" : "Empty mesh timeline stat counting devices that came within range today", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرّ %lld جهاز ضمن النطاق اليوم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আজ %lldটি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld geräte waren heute in reichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld devices passed within range today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos pasaron dentro del alcance hoy" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld device ang dumaan sa saklaw ngayong araw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld appareils sont passés à portée aujourd'hui" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld מכשירים עברו בטווח היום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज %lld डिवाइस रेंज में से गुज़रे" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld perangkat lewat dalam jangkauan hari ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivi sono passati nel raggio oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日%lld台のデバイスが範囲内を通過" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 기기 %lld대가 범위 안을 지나갔어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld peranti lalu dalam jangkauan hari ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज %lld यन्त्रहरू दायराभित्र आए" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld apparaten kwamen vandaag binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld urządzeń było dziś w zasięgu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos passaram dentro do alcance hoje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld dispositivos passaram dentro do alcance hoje" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сегодня %lld устройств прошло в зоне досягаемости" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld enheter passerade inom räckvidd idag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்று %lld சாதனங்கள் வரம்பிற்குள் கடந்தன" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วันนี้มี %lld อุปกรณ์ผ่านเข้ามาในระยะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bugün %lld cihaz menzilden geçti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сьогодні %lld пристроїв пройшло в зоні досяжності" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آج %lld ڈیوائسز رینج میں سے گزریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hôm nay có %lld thiết bị đi qua trong tầm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 %lld 台设备经过范围内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 %lld 台裝置經過範圍內" + } + } + } + }, + "content.empty.sightings_one" : { + "comment" : "Empty mesh timeline stat when exactly one device came within range today", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرّ جهاز واحد ضمن النطاق اليوم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "আজ 1টি ডিভাইস রেঞ্জের মধ্যে দিয়ে গেছে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 gerät war heute in reichweite" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 device passed within range today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo pasó dentro del alcance hoy" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 device ang dumaan sa saklaw ngayong araw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 appareil est passé à portée aujourd'hui" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מכשיר אחד עבר בטווח היום" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज 1 डिवाइस रेंज में से गुज़रा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 perangkat lewat dalam jangkauan hari ini" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo è passato nel raggio oggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "今日1台のデバイスが範囲内を通過" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오늘 기기 1대가 범위 안을 지나갔어요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 peranti lalu dalam jangkauan hari ini" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "आज 1 यन्त्र दायराभित्र आयो" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 apparaat kwam vandaag binnen bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 urządzenie było dziś w zasięgu" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo passou dentro do alcance hoje" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 dispositivo passou dentro do alcance hoje" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сегодня 1 устройство прошло в зоне досягаемости" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "1 enhet passerade inom räckvidd idag" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "இன்று 1 சாதனம் வரம்பிற்குள் கடந்தது" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "วันนี้มี 1 อุปกรณ์ผ่านเข้ามาในระยะ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "bugün 1 cihaz menzilden geçti" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "сьогодні 1 пристрій пройшов у зоні досяжності" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "آج 1 ڈیوائس رینج میں سے گزری" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "hôm nay có 1 thiết bị đi qua trong tầm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 1 台设备经过范围内" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "今天有 1 台裝置經過範圍內" + } + } + } + }, "content.empty.switch_hint" : { "comment" : "Empty timeline hint pointing at the channel switcher and the help screen", "extractionState" : "manual", @@ -28012,6 +30352,186 @@ } } }, + "content.input.note_placeholder" : { + "comment" : "Placeholder argument name for the /drop command suggestion", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رسالة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "বার্তা" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "nachricht" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "message" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensaje" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensahe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "message" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הודעה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "संदेश" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "pesan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "messaggio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "メッセージ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메시지" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "सन्देश" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "bericht" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wiadomość" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mensagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сообщение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "meddelande" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "செய்தி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ข้อความ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mesaj" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "повідомлення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "پیغام" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "tin nhắn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "訊息" + } + } + } + }, "content.input.placeholder.location" : { "comment" : "Composer placeholder for a public geohash channel, naming it", "extractionState" : "manual", @@ -50738,6 +53258,366 @@ } } }, + "notices.expiry.permanent" : { + "comment" : "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "دائم" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "স্থায়ী" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dauerhaft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קבוע" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थायी" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無期限" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "영구" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kekal" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "स्थायी" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "na stałe" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "навсегда" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "permanent" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நிரந்தரம்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ถาวร" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kalıcı" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "назавжди" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "مستقل" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "vĩnh viễn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久" + } + } + } + }, + "notices.fades" : { + "comment" : "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتلاشى %@" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ মুছে যাবে" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verblasst %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "fades %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "se desvanece %@" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "maglalaho %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "s'efface %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דוהה %@" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मिट जाएगा" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "memudar %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "svanisce %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@に消えます" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 사라짐" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "pudar %@" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ मेटिन्छ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "vervaagt %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zniknie %@" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "desvanece %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desaparece %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "исчезнет %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "tonar bort %@" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ மறையும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือนหาย %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kaybolur" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "зникне %@" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ مٹ جائے گا" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mờ dần %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@消失" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@消失" + } + } + } + }, "notices.source.mesh" : { "comment" : "Source badge for notices carried by the mesh", "extractionState" : "manual", diff --git a/bitchat/Models/CommandInfo.swift b/bitchat/Models/CommandInfo.swift index b4f309d9..2b077759 100644 --- a/bitchat/Models/CommandInfo.swift +++ b/bitchat/Models/CommandInfo.swift @@ -28,6 +28,7 @@ enum CommandInfo: String, Identifiable { case unfavorite = "unfav" case ping case trace + case drop var id: String { rawValue } @@ -41,6 +42,8 @@ enum CommandInfo: String, Identifiable { return "<" + String(localized: "content.input.group_placeholder") + ">" case .pay: return "<" + String(localized: "content.input.token_placeholder") + ">" + case .drop: + return "<" + String(localized: "content.input.note_placeholder") + ">" case .clear, .help, .who: return nil } @@ -62,11 +65,12 @@ enum CommandInfo: String, Identifiable { case .unfavorite: String(localized: "content.commands.unfavorite") case .ping: String(localized: "content.commands.ping") case .trace: String(localized: "content.commands.trace") + case .drop: String(localized: "content.commands.drop") } } static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { - var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] + var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who] // Cashu tokens are bearer instruments: in a public geohash any nearby // stranger can redeem one, so don't *suggest* /pay there (the // processor still allows it behind an explicit "public" confirm). diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 7168b818..80c57f14 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -264,7 +264,8 @@ struct NostrProtocol { geohash: String, senderIdentity: NostrIdentity, nickname: String? = nil, - expiresAt: Date? = nil + expiresAt: Date? = nil, + urgent: Bool = false ) throws -> NostrEvent { var tags = [["g", geohash]] if let nickname = nickname?.trimmedOrNilIfEmpty { @@ -273,6 +274,9 @@ struct NostrProtocol { if let expiresAt { tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))]) } + if urgent { + tags.append(["t", "urgent"]) + } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index ba46d7b3..5050ea2c 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1224,6 +1224,56 @@ final class BLEService: NSObject { return nil } + // MARK: - Archived public messages ("heard here earlier") + + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + guard let sync = gossipSyncManager else { + Task { @MainActor in completion([]) } + return + } + sync.collectPublicMessagePackets { [weak self] packets in + guard let self = self else { + Task { @MainActor in completion([]) } + return + } + // Signature verification and registry lookups run on messageQueue + // like the live receive path. + self.messageQueue.async { + let decoded = packets + .compactMap { self.decodeArchivedPublicMessage($0) } + .sorted { $0.timestamp < $1.timestamp } + Task { @MainActor in completion(decoded) } + } + } + } + + private func decodeArchivedPublicMessage(_ packet: BitchatPacket) -> ArchivedPublicMessage? { + guard packet.type == MessageType.message.rawValue, + let content = String(data: packet.payload, encoding: .utf8)?.trimmedOrNilIfEmpty + else { return nil } + let senderPeerID = PeerID(hexData: packet.senderID) + let peers = collectionsQueue.sync { peerRegistry.snapshotByID } + // Archived senders are usually long gone, so the signature-derived + // identity is the best shot at a name; a live registry entry is + // next; anonymous fallback matches the live path. + let nickname = signedSenderDisplayName(for: packet, from: senderPeerID) + ?? BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: senderPeerID, + localPeerID: myPeerID, + localNickname: myNickname, + peers: peers, + allowConnectedUnverified: false + ) + ?? BLEPeerSenderDisplayName.anonymousNickname(for: senderPeerID) + return ArchivedPublicMessage( + packetIdHex: PacketIdUtil.computeId(packet).hexEncodedString(), + senderPeerID: senderPeerID, + senderNickname: nickname, + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(packet.timestamp) / 1000) + ) + } + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { fileTransferHandler.handle(packet, from: peerID) } diff --git a/bitchat/Services/Board/BoardManager.swift b/bitchat/Services/Board/BoardManager.swift index 8c758fa8..60c6b827 100644 --- a/bitchat/Services/Board/BoardManager.swift +++ b/bitchat/Services/Board/BoardManager.swift @@ -22,7 +22,7 @@ final class BoardManager: ObservableObject { /// Publishes a bridged kind-1 note (expiring with the board post via /// NIP-40) and returns its Nostr event id, or nil when bridging failed or /// was skipped. - private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64) -> String? + private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String? /// Requests NIP-09 deletion of a previously bridged note. private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void /// Bridged Nostr event ids by postID, for merged deletes. In-memory only: @@ -34,7 +34,7 @@ final class BoardManager: ObservableObject { init( transport: Transport, store: BoardStore = .shared, - publishToNostr: ((String, String, String, UInt64) -> String?)? = nil, + publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil, deleteFromNostr: ((String, String) -> Void)? = nil ) { self.transport = transport @@ -126,7 +126,7 @@ final class BoardManager: ObservableObject { // Nostr bridge: geohash posts also go out as kind-1 location notes so // online users see them. Remember the event id for merged deletes. - if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt) { + if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) { bridgedEventIDs[postID] = eventID } return true @@ -158,7 +158,7 @@ final class BoardManager: ObservableObject { return true } - private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64) -> String? { + private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? { let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session) @@ -171,7 +171,8 @@ final class BoardManager: ObservableObject { geohash: geohash, senderIdentity: identity, nickname: nickname, - expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000) + expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000), + urgent: urgent ) NostrRelayManager.shared.sendEvent(event, to: relays) return event.id diff --git a/bitchat/Services/Board/UnifiedNotices.swift b/bitchat/Services/Board/UnifiedNotices.swift index 16d88a97..31bc14c8 100644 --- a/bitchat/Services/Board/UnifiedNotices.swift +++ b/bitchat/Services/Board/UnifiedNotices.swift @@ -23,6 +23,9 @@ struct NoticeItem: Identifiable, Equatable { let content: String let createdAt: Date let isUrgent: Bool + /// When the notice fades (board expiry or a note's NIP-40 tag, as dead + /// drops carry). Nil means it only ages out of the relay window. + let expiresAt: Date? let source: Source var isBoardPost: Bool { @@ -36,6 +39,9 @@ struct NoticeItem: Identifiable, Equatable { content = post.content createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000) isUrgent = post.isUrgent + expiresAt = post.expiresAt > 0 + ? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000) + : nil source = .board(post) } @@ -46,7 +52,8 @@ struct NoticeItem: Identifiable, Equatable { .first.map(String.init) ?? display content = note.content createdAt = note.createdAt - isUrgent = false + isUrgent = note.isUrgent + expiresAt = note.expiresAt source = .nostr(note) } } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index b8a4785e..9f125291 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -146,6 +146,8 @@ final class CommandProcessor { return handleTrace(args) case "/pay": return handlePay(args) + case "/drop": + return handleDrop(args) case "/help": return .success(message: Self.helpText) default: @@ -171,9 +173,36 @@ final class CommandProcessor { /ping @name — measure round-trip time (mesh only) /trace @name — estimated mesh path (mesh only) /pay — send a cashu ecash token in this chat + /drop — pin a note to this place for 24h (needs location) /help — this list """ + /// /drop — a dead drop: pins a note to the current building-level + /// geohash with a 24h NIP-40 expiry. Anyone who passes through here and + /// looks at notices (or hits the empty-timeline "notes left here" hint) + /// reads it. + private func handleDrop(_ args: String) -> CommandResult { + guard LocationNotesSettings.enabled else { + return .error(message: "location notes are off — enable them in the info screen") + } + guard let content = args.trimmedOrNilIfEmpty else { + return .error(message: "usage: /drop ") + } + let location = LocationChannelManager.shared + guard location.permissionState == .authorized else { + return .error(message: "leaving a note needs location — enable it in the info screen") + } + guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else { + location.refreshChannels() + return .error(message: "still finding this place — try again in a moment") + } + guard let nickname = contextProvider?.nickname, + LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else { + return .error(message: "no geo relays reachable — note not left") + } + return .success(message: "📍 note left here — it fades in 24h") + } + // MARK: - Command Handlers private func handleMessage(_ args: String) -> CommandResult { diff --git a/bitchat/Services/GeohashChatActivityTracker.swift b/bitchat/Services/GeohashChatActivityTracker.swift new file mode 100644 index 00000000..8d8f71a8 --- /dev/null +++ b/bitchat/Services/GeohashChatActivityTracker.swift @@ -0,0 +1,122 @@ +// +// GeohashChatActivityTracker.swift +// bitchat +// +// Tracks actual chat-message activity per sampled geohash so the empty mesh +// timeline can point at a nearby channel where a conversation is happening — +// not merely where participants are present. +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// A recent chat message observed in a sampled geohash channel. +struct GeohashChatPreview: Equatable, Sendable { + let senderName: String + let content: String + let timestamp: Date + + init(senderName: String, content: String, timestamp: Date) { + self.senderName = senderName + self.content = content + self.timestamp = timestamp + } +} + +/// The liveliest nearby conversation, resolved against the user's regional +/// channels. +struct NearbyConversation: Equatable, Sendable { + let channel: GeohashChannel + /// Chat messages seen within the activity window. + let messageCount: Int + let lastMessage: GeohashChatPreview +} + +/// Records kind-20000 chat events seen by the background geohash sampling +/// subscriptions (blocked and self senders are filtered by the caller) and +/// answers "where nearby is a conversation actually happening?". +@MainActor +final class GeohashChatActivityTracker: ObservableObject { + static let shared = GeohashChatActivityTracker() + + /// How far back a message still counts as "a conversation is happening". + private let window: TimeInterval + /// Per-geohash recent message timestamps (pruned to the window). + private var messageTimes: [String: [Date]] = [:] + /// Per-geohash newest message preview. + private var lastMessages: [String: GeohashChatPreview] = [:] + private let now: () -> Date + + init( + window: TimeInterval = TransportConfig.uiGeohashChatActivityWindowSeconds, + now: @escaping () -> Date = { Date() } + ) { + self.window = window + self.now = now + } + + func recordChatMessage( + geohash: String, + senderName: String, + content: String, + timestamp: Date + ) { + let gh = geohash.lowercased() + let clamped = min(timestamp, now()) + guard now().timeIntervalSince(clamped) < window else { return } + + var times = messageTimes[gh] ?? [] + times.append(clamped) + messageTimes[gh] = prune(times) + + if let existing = lastMessages[gh], existing.timestamp > clamped { + // Keep the newer preview. + } else { + lastMessages[gh] = GeohashChatPreview(senderName: senderName, content: content, timestamp: clamped) + } + objectWillChange.send() + } + + /// Messages seen in the window for one geohash. + func messageCount(for geohash: String) -> Int { + prune(messageTimes[geohash.lowercased()] ?? []).count + } + + func lastMessage(for geohash: String) -> GeohashChatPreview? { + let gh = geohash.lowercased() + guard messageCount(for: gh) > 0 else { return nil } + return lastMessages[gh] + } + + /// The busiest channel with at least one chat message in the window. + /// Ties go to the more local (higher-precision) channel, so a lone + /// message on your block beats a lone message across the region. + func mostActiveConversation(among channels: [GeohashChannel]) -> NearbyConversation? { + var best: NearbyConversation? + for channel in channels { + let count = messageCount(for: channel.geohash) + guard count > 0, let last = lastMessage(for: channel.geohash) else { continue } + let candidate = NearbyConversation(channel: channel, messageCount: count, lastMessage: last) + if let current = best { + let better = count > current.messageCount + || (count == current.messageCount + && channel.level.precision > current.channel.level.precision) + if better { best = candidate } + } else { + best = candidate + } + } + return best + } + + func clear() { + messageTimes.removeAll() + lastMessages.removeAll() + objectWillChange.send() + } + + private func prune(_ times: [Date]) -> [Date] { + let cutoff = now().addingTimeInterval(-window) + return times.filter { $0 >= cutoff } + } +} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index e95f536a..4b5577a1 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -70,6 +70,30 @@ final class LocationNotesManager: ObservableObject { /// The matched `g` tag: the cell the note was posted to, which can be /// a neighbor of the subscribed geohash. let geohash: String + /// NIP-40 expiration, when the note carries one (dead drops do). + let expiresAt: Date? + /// Carries a `["t","urgent"]` tag (parity with urgent board posts). + let isUrgent: Bool + + init( + id: String, + pubkey: String, + content: String, + createdAt: Date, + nickname: String?, + geohash: String, + expiresAt: Date? = nil, + isUrgent: Bool = false + ) { + self.id = id + self.pubkey = pubkey + self.content = content + self.createdAt = createdAt + self.nickname = nickname + self.geohash = geohash + self.expiresAt = expiresAt + self.isUrgent = isUrgent + } var displayName: String { let suffix = String(pubkey.suffix(4)) @@ -90,6 +114,7 @@ final class LocationNotesManager: ObservableObject { private var subscriptionID: String? private var noteIDs = Set() // O(1) duplicate detection private var directoryUpdateCancellable: AnyCancellable? + private var expiryPruneTimer: Timer? private let dependencies: LocationNotesDependencies private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) @@ -123,6 +148,33 @@ final class LocationNotesManager: ObservableObject { self.subscribe() } } + // NIP-40 notes can expire while displayed (a 24h dead drop crossing + // its boundary); ingest-time filtering alone would keep it visible + // until the subscription is recreated. + expiryPruneTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.pruneExpiredNotes() + } + } + } + + deinit { + expiryPruneTimer?.invalidate() + } + + /// Drops notes whose NIP-40 expiry has passed. Their ids stay in + /// `noteIDs` so a relay replay cannot resurrect them. + func pruneExpiredNotes() { + let now = dependencies.now() + let expired = notes.contains { note in + if let expiresAt = note.expiresAt { return expiresAt <= now } + return false + } + guard expired else { return } + notes.removeAll { note in + if let expiresAt = note.expiresAt { return expiresAt <= now } + return false + } } func setGeohash(_ newGeohash: String) { @@ -202,10 +254,15 @@ final class LocationNotesManager: ObservableObject { tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased()) })?[1].lowercased() else { return } guard !self.noteIDs.contains(event.id) else { return } + // NIP-40: relays are not required to enforce expiration — drop + // expired notes client-side so 24h dead drops actually vanish. + let expiresAt = Self.expirationDate(of: event) + if let expiresAt, expiresAt <= self.dependencies.now() { return } self.noteIDs.insert(event.id) let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash) + let urgent = event.tags.contains { $0.count >= 2 && $0[0].lowercased() == "t" && $0[1].lowercased() == "urgent" } + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick, geohash: matchedGeohash, expiresAt: expiresAt, isUrgent: urgent) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } self.enforceMemoryCap() @@ -219,8 +276,10 @@ final class LocationNotesManager: ObservableObject { }) } - /// Send a location note for the current geohash using the per-geohash identity. - func send(content: String, nickname: String) { + /// Send a location note for the current geohash using the per-geohash + /// identity, optionally expiring via NIP-40 (dead drops pass 24h; the + /// composer's ∞ option passes nil) and optionally tagged urgent. + func send(content: String, nickname: String, expiresAt: Date? = nil, urgent: Bool = false) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) guard !relays.isEmpty else { @@ -235,7 +294,9 @@ final class LocationNotesManager: ObservableObject { content: trimmed, geohash: geohash, senderIdentity: id, - nickname: nickname + nickname: nickname, + expiresAt: expiresAt, + urgent: urgent ) dependencies.sendEvent(event, relays) // Optimistic local-echo @@ -245,7 +306,9 @@ final class LocationNotesManager: ObservableObject { content: trimmed, createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)), nickname: nickname, - geohash: geohash + geohash: geohash, + expiresAt: expiresAt, + isUrgent: urgent ) self.noteIDs.insert(event.id) self.notes.insert(echo, at: 0) @@ -288,6 +351,14 @@ final class LocationNotesManager: ObservableObject { } } + /// The NIP-40 `expiration` tag as a date, if the event carries one. + static func expirationDate(of event: NostrEvent) -> Date? { + guard let tag = event.tags.first(where: { $0.count >= 2 && $0[0].lowercased() == "expiration" }), + let seconds = TimeInterval(tag[1]) + else { return nil } + return Date(timeIntervalSince1970: seconds) + } + /// Enforces defensive memory cap on notes array (keeps newest). private func enforceMemoryCap() { if notes.count > maxNotesInMemory { @@ -297,7 +368,43 @@ final class LocationNotesManager: ObservableObject { } } - /// Explicitly cancel subscription and release resources. + /// One-shot dead-drop publish without holding a subscription: pins a + /// note to `geohash` that expires via NIP-40. Returns false when no geo + /// relays are known or signing fails. + @MainActor + static func postDrop( + content: String, + nickname: String, + geohash: String, + expiry: TimeInterval = TransportConfig.locationDropExpirySeconds, + dependencies: LocationNotesDependencies = .live + ) -> Bool { + guard let trimmed = content.trimmedOrNilIfEmpty else { return false } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + SecureLogger.warning("LocationNotesManager: drop blocked, no geo relays for geohash=\(geohash)", category: .session) + return false + } + do { + let identity = try dependencies.deriveIdentity(geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: trimmed, + geohash: geohash, + senderIdentity: identity, + nickname: nickname, + expiresAt: dependencies.now().addingTimeInterval(expiry) + ) + dependencies.sendEvent(event, relays) + return true + } catch { + SecureLogger.error("LocationNotesManager: failed to post drop: \(error)", category: .session) + return false + } + } + + /// Explicitly cancel the subscription. The prune timer stays alive (it + /// holds only a weak self) so a reused instance — the notices sheet + /// cancels on tab switch and refreshes on return — keeps pruning. func cancel() { if let sub = subscriptionID { dependencies.unsubscribe(sub) diff --git a/bitchat/Services/LocationNotesSettings.swift b/bitchat/Services/LocationNotesSettings.swift new file mode 100644 index 00000000..5bda810f --- /dev/null +++ b/bitchat/Services/LocationNotesSettings.swift @@ -0,0 +1,29 @@ +// +// LocationNotesSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// User preference for location notes (dead drops): leaving notes pinned to +/// nearby places with /drop and surfacing notes others left here. On by +/// default, but everything it powers additionally requires location +/// permission — the toggle in app info is the kill switch. +enum LocationNotesSettings { + private static let enabledKey = "locationNotes.enabled" + + /// Fired on every toggle write so live consumers (the nearby-notes + /// counter) can drop or restart their relay subscription immediately. + static let didChangeNotification = Notification.Name("bitchat.locationNotesSettingsDidChange") + + static var enabled: Bool { + get { UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true } + set { + UserDefaults.standard.set(newValue, forKey: enabledKey) + NotificationCenter.default.post(name: didChangeNotification, object: nil) + } + } +} diff --git a/bitchat/Services/MeshEchoSettings.swift b/bitchat/Services/MeshEchoSettings.swift new file mode 100644 index 00000000..347c178a --- /dev/null +++ b/bitchat/Services/MeshEchoSettings.swift @@ -0,0 +1,27 @@ +// +// MeshEchoSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Watermark for "heard here earlier" echoes: clearing the mesh timeline +/// (triple-tap or /clear) records the moment, and the next launch only +/// re-seeds archived messages heard after it. The archive itself is left +/// alone — the device keeps carrying those messages for peers; the user +/// just doesn't want to see them again. +enum MeshEchoSettings { + private static let clearedThroughKey = "meshEchoes.clearedThrough" + + static var clearedThrough: Date? { + get { UserDefaults.standard.object(forKey: clearedThroughKey) as? Date } + set { UserDefaults.standard.set(newValue, forKey: clearedThroughKey) } + } + + static func reset() { + UserDefaults.standard.removeObject(forKey: clearedThroughKey) + } +} diff --git a/bitchat/Services/MeshSightingsTracker.swift b/bitchat/Services/MeshSightingsTracker.swift new file mode 100644 index 00000000..1a5a9d80 --- /dev/null +++ b/bitchat/Services/MeshSightingsTracker.swift @@ -0,0 +1,106 @@ +// +// MeshSightingsTracker.swift +// bitchat +// +// Privacy-preserving daily tally of mesh peers that came within radio range, +// so an empty timeline can say "3 devices passed within range today" instead +// of feeling dead. Stores only a per-day salted hash per peer plus a count — +// no identities, no history beyond today. +// This is free and unencumbered software released into the public domain. +// + +import BitFoundation +import CryptoKit +import Foundation + +@MainActor +final class MeshSightingsTracker: ObservableObject { + static let shared = MeshSightingsTracker() + + private enum Keys { + static let dayKey = "meshSightings.dayKey" + static let salt = "meshSightings.salt" + static let hashes = "meshSightings.hashes" + static let lastSeenAt = "meshSightings.lastSeenAt" + } + + /// Distinct devices seen within range today (rotating peer IDs may count + /// a long-lived neighbor more than once across rotations; that is fine + /// for an ambient stat). + @Published private(set) var todayCount: Int = 0 + @Published private(set) var lastSightingAt: Date? + + private let defaults: UserDefaults + private let now: () -> Date + private var seenHashes: Set = [] + + init(defaults: UserDefaults = .standard, now: @escaping () -> Date = { Date() }) { + self.defaults = defaults + self.now = now + restore() + } + + func recordSighting(peerID: PeerID) { + rollOverIfNeeded() + let hash = saltedHash(peerID.id) + let seenAt = now() + lastSightingAt = seenAt + defaults.set(seenAt, forKey: Keys.lastSeenAt) + guard seenHashes.insert(hash).inserted else { return } + todayCount = seenHashes.count + defaults.set(Array(seenHashes), forKey: Keys.hashes) + } + + func clear() { + seenHashes.removeAll() + todayCount = 0 + lastSightingAt = nil + defaults.removeObject(forKey: Keys.dayKey) + defaults.removeObject(forKey: Keys.salt) + defaults.removeObject(forKey: Keys.hashes) + defaults.removeObject(forKey: Keys.lastSeenAt) + } + + private func restore() { + rollOverIfNeeded() + seenHashes = Set(defaults.stringArray(forKey: Keys.hashes) ?? []) + todayCount = seenHashes.count + lastSightingAt = defaults.object(forKey: Keys.lastSeenAt) as? Date + } + + /// Resets the tally when the local calendar day changes; the salt rotates + /// with it so hashes from different days can never be correlated. + private func rollOverIfNeeded() { + let today = Self.dayKey(for: now()) + guard defaults.string(forKey: Keys.dayKey) != today else { return } + defaults.set(today, forKey: Keys.dayKey) + defaults.set(Self.randomSalt(), forKey: Keys.salt) + defaults.removeObject(forKey: Keys.hashes) + seenHashes.removeAll() + todayCount = 0 + } + + private func saltedHash(_ value: String) -> String { + let salt = defaults.data(forKey: Keys.salt) ?? { + let fresh = Self.randomSalt() + defaults.set(fresh, forKey: Keys.salt) + return fresh + }() + var digest = SHA256() + digest.update(data: salt) + digest.update(data: Data(value.utf8)) + return digest.finalize().map { String(format: "%02x", $0) }.joined() + } + + private static func dayKey(for date: Date) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func randomSalt() -> Data { + Data((0..<16).map { _ in UInt8.random(in: .min ... .max) }) + } +} diff --git a/bitchat/Services/NotificationService.swift b/bitchat/Services/NotificationService.swift index 091f4289..cc75bcef 100644 --- a/bitchat/Services/NotificationService.swift +++ b/bitchat/Services/NotificationService.swift @@ -26,6 +26,10 @@ protocol NotificationRequestDelivering { func add(_ request: UNNotificationRequest) } +protocol NotificationCategoryRegistering { + func setCategories(_ categories: Set) +} + private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing { private let center: UNUserNotificationCenter @@ -55,6 +59,18 @@ private final class NotificationCenterRequestDelivererAdapter: NotificationReque } } +private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering { + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func setCategories(_ categories: Set) { + center.setNotificationCategories(categories) + } +} + private struct NoopNotificationAuthorizer: NotificationAuthorizing { func requestAuthorization( options: UNAuthorizationOptions, @@ -68,12 +84,21 @@ private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering { func add(_ request: UNNotificationRequest) {} } +private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering { + func setCategories(_ categories: Set) {} +} + final class NotificationService { static let shared = NotificationService() + /// Category for the "bitchatters nearby" notification, carrying the wave quick action. + static let nearbyCategoryID = "chat.bitchat.category.nearby" + static let waveActionID = "chat.bitchat.action.wave" + private let isRunningTestsProvider: () -> Bool private let authorizer: NotificationAuthorizing private let requestDeliverer: NotificationRequestDelivering + private let categoryRegistrar: NotificationCategoryRegistering /// Returns true if running in test environment (XCTest, Swift Testing, or CI) private var isRunningTests: Bool { @@ -92,25 +117,30 @@ final class NotificationService { if isRunningTestsProvider() { self.authorizer = NoopNotificationAuthorizer() self.requestDeliverer = NoopNotificationRequestDeliverer() + self.categoryRegistrar = NoopNotificationCategoryRegistrar() } else { let center = UNUserNotificationCenter.current() self.authorizer = NotificationCenterAuthorizerAdapter(center: center) self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center) + self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center) } } internal init( isRunningTestsProvider: @escaping () -> Bool, authorizer: NotificationAuthorizing, - requestDeliverer: NotificationRequestDelivering + requestDeliverer: NotificationRequestDelivering, + categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar() ) { self.isRunningTestsProvider = isRunningTestsProvider self.authorizer = authorizer self.requestDeliverer = requestDeliverer + self.categoryRegistrar = categoryRegistrar } func requestAuthorization() { guard !isRunningTests else { return } + registerCategories() authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in if granted { // Permission granted @@ -119,13 +149,29 @@ final class NotificationService { } } } + + private func registerCategories() { + let wave = UNNotificationAction( + identifier: Self.waveActionID, + title: "wave 👋", + options: [] + ) + let nearby = UNNotificationCategory( + identifier: Self.nearbyCategoryID, + actions: [wave], + intentIdentifiers: [], + options: [] + ) + categoryRegistrar.setCategories([nearby]) + } func sendLocalNotification( title: String, body: String, identifier: String, userInfo: [String: Any]? = nil, - interruptionLevel: UNNotificationInterruptionLevel = .active + interruptionLevel: UNNotificationInterruptionLevel = .active, + categoryIdentifier: String? = nil ) { guard !isRunningTests else { return } let content = UNMutableNotificationContent() @@ -133,6 +179,9 @@ final class NotificationService { content.body = body content.sound = .default content.interruptionLevel = interruptionLevel + if let categoryIdentifier = categoryIdentifier { + content.categoryIdentifier = categoryIdentifier + } if let userInfo = userInfo { content.userInfo = userInfo @@ -183,7 +232,8 @@ final class NotificationService { title: title, body: body, identifier: identifier, - interruptionLevel: .timeSensitive + interruptionLevel: .timeSensitive, + categoryIdentifier: Self.nearbyCategoryID ) } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 62712db7..43ece9ea 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -211,6 +211,32 @@ protocol Transport: AnyObject { // Pending file management (BCH-01-002: files held in memory until user accepts) func acceptPendingFile(id: String) -> URL? func declinePendingFile(id: String) + + // Store-and-forward archive (mesh transports only): the public messages + // this device is carrying for gossip sync, decoded for display as + // "heard here earlier" timeline echoes. + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) +} + +/// A carried public mesh message from the store-and-forward window, decoded +/// for display. `packetIdHex` is stable across launches so echo rows keep a +/// deterministic message ID. +struct ArchivedPublicMessage { + let packetIdHex: String + let senderPeerID: PeerID + let senderNickname: String + let content: String + let timestamp: Date +} + +extension BitchatMessage { + /// Echo rows are minted locally with this prefix (packet-id derived, so + /// stable across launches); the timeline dims them. + static let archivedEchoIDPrefix = "echo-" + + var isArchivedEcho: Bool { + id.hasPrefix(Self.archivedEchoIDPrefix) + } } extension Transport { @@ -261,6 +287,10 @@ extension Transport { func acceptPendingFile(id: String) -> URL? { nil } func declinePendingFile(id: String) {} + + func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) { + Task { @MainActor in completion([]) } + } } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 22069296..b86791be 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -172,6 +172,14 @@ enum TransportConfig { static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLimit: Int = 100 static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400 + // A sampled chat message this recent means "a conversation is happening + // there" for the empty-timeline nearby-activity hint. + static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 + // Startup delay before reading the gossip archive for "heard here + // earlier" echoes; covers the archive's async disk restore. + static let uiArchivedEchoLoadDelaySeconds: TimeInterval = 1.5 + // Dead drops: location notes left via /drop expire after this long. + static let locationDropExpirySeconds: TimeInterval = 24 * 60 * 60 // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift index b9b0f771..b619e75d 100644 --- a/bitchat/Sync/GossipSyncManager.swift +++ b/bitchat/Sync/GossipSyncManager.swift @@ -652,6 +652,19 @@ final class GossipSyncManager { } } + /// Snapshot of the carried public-message packets (fresh window only), + /// for the "heard here earlier" timeline echoes. Completion runs on the + /// sync queue. + func collectPublicMessagePackets(completion: @escaping ([BitchatPacket]) -> Void) { + queue.async { [weak self] in + guard let self else { + completion([]) + return + } + completion(self.messages.allPackets(isFresh: self.isPacketFresh)) + } + } + private func performPeriodicMaintenance(now: Date = Date()) { cleanupExpiredMessages() cleanupStaleAnnouncementsIfNeeded(now: now) diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 8d8b6a9e..d8005099 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -112,6 +112,13 @@ final class ChatOutgoingCoordinator { sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel) } } + + /// Broadcasts a wave on the mesh channel regardless of the active channel — + /// used by the "bitchatters nearby" notification quick action, which always + /// refers to mesh peers. + func sendMeshWave() { + sendMeshPublicMessage(originalContent: "👋", trimmed: "👋", mentions: []) + } } private extension ChatOutgoingCoordinator { diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 77a40c6b..bd045939 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -36,6 +36,9 @@ protocol ChatPeerListContext: AnyObject { // MARK: Notifications /// Posts the "bitchatters nearby" local notification. func notifyNetworkAvailable(peerCount: Int) + + /// Records peers seen within range for the daily ambient sightings tally. + func recordMeshSightings(peerIDs: [PeerID]) } extension ChatViewModel: ChatPeerListContext { @@ -60,6 +63,12 @@ extension ChatViewModel: ChatPeerListContext { func notifyNetworkAvailable(peerCount: Int) { NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) } + + func recordMeshSightings(peerIDs: [PeerID]) { + for peerID in peerIDs { + MeshSightingsTracker.shared.recordSighting(peerID: peerID) + } + } } final class ChatPeerListCoordinator: @unchecked Sendable { @@ -129,6 +138,7 @@ private extension ChatPeerListCoordinator { } invalidateNetworkEmptyTimer() + context.recordMeshSightings(peerIDs: meshPeers) let newPeers = meshPeerSet.subtracting(recentlySeenPeers) // Record every sighted peer even when no notification fires. A peer diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index f5f57cf1..b6623f3a 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -288,6 +288,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func clearCurrentPublicTimeline() { context.clearPublicConversation(ConversationID(channelID: context.activeChannel)) + // Clearing the mesh timeline also dismisses its archived echoes for + // good: the watermark stops the next launch from re-seeding them + // (the archive itself keeps carrying the messages for peers), and + // the dedup keys go so a cleared message arriving live shows again. + if case .mesh = context.activeChannel { + MeshEchoSettings.clearedThrough = Date() + archivedEchoKeys.removeAll() + } + // The SPM test process shares the real Application Support tree, so this // detached deletion can land mid-test under parallel scheduling and flake // a file-dependent test. Tests never need the on-disk media cleared. @@ -407,6 +416,21 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender /// rate limit; low/no-PoW events keep the strict limits so old clients /// still get through at normal rates. + /// Identity keys of the archived echoes seeded into the mesh timeline at + /// launch. The mesh wire format carries no stable message ID, so a + /// re-synced copy of an already-rendered echo arrives with a fresh UUID — + /// this content identity is the only way to recognize it. + private var archivedEchoKeys = Set() + + func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) { + archivedEchoKeys.insert(Self.archivedEchoKey(senderPeerID: senderPeerID, timestamp: timestamp, content: content)) + } + + static func archivedEchoKey(senderPeerID: PeerID?, timestamp: Date, content: String) -> String { + let ms = UInt64((timestamp.timeIntervalSince1970 * 1000).rounded()) + return "\(senderPeerID?.id ?? "")|\(ms)|\(content)" + } + func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) { let finalMessage = context.processActionMessage(message) if context.isMessageBlocked(finalMessage) { return } @@ -442,6 +466,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } guard let destination else { return } + // A live copy of a message already rendered as an archived echo + // (e.g. re-served by a peer's gossip sync) would duplicate the row. + if destination == .mesh, !isSystem { + let key = Self.archivedEchoKey( + senderPeerID: finalMessage.senderPeerID, + timestamp: finalMessage.timestamp, + content: finalMessage.content + ) + if archivedEchoKeys.contains(key) { return } + } + let channelMatches: Bool = { switch context.activeChannel { case .mesh: return !isGeo || isSystem diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 0877090e..e9b84e64 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -904,6 +904,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele outgoingCoordinator.sendMessage(content) } + /// Sends a 👋 to the mesh channel regardless of the active channel. + @MainActor + func sendMeshWave() { + outgoingCoordinator.sendMeshWave() + } + // MARK: - Geohash Participants @MainActor @@ -1169,6 +1175,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele GossipMessageArchive.wipeDefault() StoreAndForwardMetrics.shared.reset() + // Ambient-liveliness bookkeeping: sampled nearby-chat previews, the + // daily sightings tally, and the echoes-dismissed watermark + GeohashChatActivityTracker.shared.clear() + MeshSightingsTracker.shared.clear() + MeshEchoSettings.reset() + // Drop private group keys and rosters (keychain + disk) groupStore.wipe() // Drop cached peers' prekey bundles (who we could write to is diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 8332efab..55a8925a 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -178,6 +178,8 @@ private extension ChatViewModelBootstrapper { viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator + loadArchivedEchoes() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in guard let viewModel, let bleService = viewModel.meshService as? BLEService else { return } @@ -197,6 +199,58 @@ private extension ChatViewModelBootstrapper { } } + /// Surfaces the carried store-and-forward window (up to 6h of public + /// mesh messages, persisted across restarts) as dimmed "heard here + /// earlier" rows, so the mesh timeline opens with the place's memory + /// instead of a void. The archive restore runs async on the sync queue + /// right after transport start, so give it a beat before asking. + private func loadArchivedEchoes() { + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiArchivedEchoLoadDelaySeconds) { [weak viewModel] in + guard let viewModel else { return } + viewModel.meshService.collectArchivedPublicMessages { [weak viewModel] allArchived in + // A previous /clear dismissed everything heard up to its + // watermark; only newer archive entries come back. + let clearedThrough = MeshEchoSettings.clearedThrough ?? .distantPast + let archived = allArchived.filter { $0.timestamp > clearedThrough } + guard let viewModel, !archived.isEmpty else { return } + // Seed only an untouched timeline: with live rows already + // present (or after /clear) splicing history back in would + // be wrong. + guard viewModel.conversations.conversationsByID[.mesh]?.messages.isEmpty != false else { return } + + for item in archived { + let echo = BitchatMessage( + id: BitchatMessage.archivedEchoIDPrefix + item.packetIdHex, + sender: item.senderNickname, + content: item.content, + timestamp: item.timestamp, + isRelay: false, + senderPeerID: item.senderPeerID + ) + viewModel.publicConversationCoordinator.registerArchivedEcho( + senderPeerID: item.senderPeerID, + timestamp: item.timestamp, + content: item.content + ) + _ = viewModel.appendPublicMessage(echo, to: .mesh) + } + + if let firstTimestamp = archived.map(\.timestamp).min() { + // Echo-prefixed ID so the divider joins the tinted, + // dimmed echo block in the timeline. + let divider = BitchatMessage( + id: BitchatMessage.archivedEchoIDPrefix + "divider", + sender: "system", + content: String(localized: "content.echoes.divider", comment: "System line shown above dimmed archived messages replayed on the mesh timeline at launch"), + timestamp: firstTimestamp.addingTimeInterval(-1), + isRelay: false + ) + _ = viewModel.appendPublicMessage(divider, to: .mesh) + } + } + } + } + func bindPeerService() { viewModel.unifiedPeerService.$peers .receive(on: DispatchQueue.main) diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index ffe423b5..4fa22ade 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -115,6 +115,16 @@ final class GeoPresenceTracker { my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + + // Non-empty content on a sampled event means an actual chat message + // (presence events are empty) — feed the nearby-conversation hint. + GeohashChatActivityTracker.shared.recordChatMessage( + geohash: gh, + senderName: Self.sampledSenderName(for: event, context: context), + content: content, + timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)) + ) + guard existingCount == 0 else { return } let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -131,6 +141,19 @@ final class GeoPresenceTracker { cooldownPerGeohash(gh, content: content, event: event) } + /// Attribution for a sampled event: the event's own `n` tag wins (the + /// active-channel nickname table only covers the selected geohash), + /// falling back to the table, then "anon", always suffixed with the + /// pubkey tail like every other geohash display name. + @MainActor + static func sampledSenderName(for event: NostrEvent, context: any GeoPresenceContext) -> String { + let suffix = String(event.pubkey.suffix(4)) + let tagNick = event.tags.first { $0.count >= 2 && $0[0].lowercased() == "n" }?[1] + let nick = tagNick?.trimmedOrNilIfEmpty + ?? context.geoNicknames[event.pubkey.lowercased()]?.trimmedOrNilIfEmpty + return (nick ?? "anon") + "#" + suffix + } + @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { guard let context else { return } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index 8335d874..a850f3e4 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -10,6 +10,8 @@ struct AppInfoView: View { var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)? @State private var showTopology = false @State private var liveVoiceEnabled = PTTSettings.liveVoiceEnabled + @State private var locationNotesEnabled = LocationNotesSettings.enabled + @ObservedObject private var locationManager = LocationChannelManager.shared private var selectedTheme: AppTheme { AppTheme(rawValue: appThemeRawValue) ?? .matrix @@ -88,6 +90,17 @@ struct AppInfoView: View { ) } + 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 { static let title: LocalizedStringKey = "app_info.network.title" static let topology = AppInfoFeatureInfo( @@ -250,19 +263,24 @@ struct AppInfoView: View { } } - // Voice + // Location (notes / dead drops) VStack(alignment: .leading, spacing: 16) { - SectionHeader(Strings.Voice.title) + SectionHeader(Strings.Location.title) HStack(spacing: 0) { - FeatureRow(info: Strings.Voice.live) - Toggle(Strings.Voice.live.title, isOn: $liveVoiceEnabled) + FeatureRow(info: Strings.Location.notes) + Toggle(Strings.Location.notes.title, isOn: $locationNotesEnabled) .labelsHidden() .tint(palette.accent) - .onChange(of: liveVoiceEnabled) { newValue in - PTTSettings.liveVoiceEnabled = newValue + .onChange(of: locationNotesEnabled) { newValue in + LocationNotesSettings.enabled = newValue + if newValue { + locationManager.enableLocationChannels() + } } } + + locationPermissionRow } // Network diagnostics @@ -340,6 +358,34 @@ 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: LocalizedStringKey diff --git a/bitchat/Views/Components/MeshEmptyStateView.swift b/bitchat/Views/Components/MeshEmptyStateView.swift new file mode 100644 index 00000000..fad25b21 --- /dev/null +++ b/bitchat/Views/Components/MeshEmptyStateView.swift @@ -0,0 +1,180 @@ +// +// MeshEmptyStateView.swift +// bitchat +// +// The empty mesh timeline, upgraded from a dead end into a live surface: +// a sonar shows the radio scanning, the daily sightings tally proves the +// spot isn't dead, the liveliest nearby geohash conversation is one tap +// away, and notes left at this place surface when there are any. +// This is free and unencumbered software released into the public domain. +// + +import SwiftUI + +struct MeshEmptyStateView: View { + /// Visible chat height to fill; the radar centers in the space left + /// below the narration. Zero (previews) keeps a compact layout. + var fillHeight: CGFloat = 0 + /// Ambient-footer mode, appended below archived echoes: skips the + /// intro/help narration (the timeline isn't empty) and shrinks the + /// radar, keeping the sightings tally and the live hints visible. + var compact: Bool = false + + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel + @ObservedObject private var activityTracker = GeohashChatActivityTracker.shared + @ObservedObject private var sightingsTracker = MeshSightingsTracker.shared + + @ThemedPalette private var palette + + /// The activity window is evaluated at render time; without new events + /// nothing would trigger a re-render, so a stale "people are talking" + /// hint could linger. A slow tick keeps the hints and relative times + /// honest. + @State private var refreshTick = 0 + private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() + + private enum Strings { + static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is") + static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen") + static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today") + + static func sightingsMany(_ count: Int) -> String { + String( + format: String(localized: "content.empty.sightings_many", comment: "Empty mesh timeline stat counting devices that came within range today"), + locale: .current, + count + ) + } + + static func activityOne(_ geohash: String) -> String { + String( + format: String(localized: "content.empty.activity_one", comment: "Empty mesh timeline hint when one person is chatting in a nearby geohash channel; placeholder is the geohash"), + locale: .current, + geohash + ) + } + + static func activityMany(_ geohash: String) -> String { + String( + format: String(localized: "content.empty.activity_many", comment: "Empty mesh timeline hint when several people are chatting in a nearby geohash channel; placeholder is the geohash"), + locale: .current, + geohash + ) + } + + } + + /// The radar means "searching for people": once anyone is connected or + /// reachable on the mesh, the search is over and the sweep goes away. + private var isSearchingForPeers: Bool { + peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0 + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if compact { + if isSearchingForPeers { + radarBlock + } + if let conversation = nearbyConversation { + conversationHint(conversation) + } + } else { + // The radar + tally already say "scanning, nobody yet", so + // the narration stays to two lines with the live hint after + // them, not wedged in between. + narrationLine(Strings.meshIntro) + narrationLine(Strings.switchHint) + if let conversation = nearbyConversation { + conversationHint(conversation) + } + + // The radar centers in whatever space is left below the + // text — the flexible spacers split it evenly. + if isSearchingForPeers { + Spacer(minLength: 24) + radarBlock + Spacer(minLength: 12) + } + } + } + .frame(minHeight: compact ? 0 : fillHeight, alignment: .top) + .onReceive(refreshTimer) { _ in refreshTick += 1 } + } + + /// The radar with today's tally as its caption — the stat belongs to + /// the scanning visual, not the narration lines. + private var radarBlock: some View { + VStack(spacing: 4) { + MeshRadarView(height: compact ? 44 : 72) + if sightingsTracker.todayCount > 0 { + Text(verbatim: sightingsText) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + } + } + .frame(maxWidth: .infinity) + } +} + +private extension MeshEmptyStateView { + var nearbyConversation: NearbyConversation? { + activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels) + } + + var sightingsText: String { + sightingsTracker.todayCount == 1 + ? Strings.sightingsOne + : Strings.sightingsMany(sightingsTracker.todayCount) + } + + func conversationHint(_ conversation: NearbyConversation) -> some View { + let headline = conversation.messageCount == 1 + ? Strings.activityOne(conversation.channel.geohash) + : Strings.activityMany(conversation.channel.geohash) + + return Button { + locationChannelsModel.markTeleported(for: conversation.channel.geohash, false) + locationChannelsModel.select(.location(conversation.channel)) + } label: { + VStack(alignment: .leading, spacing: 2) { + actionLine("💬 \(headline)") + narrationLine(" \(previewText(for: conversation.lastMessage))") + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + func previewText(for message: GeohashChatPreview) -> String { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + var content = message.content + if content.count > maxLen { + content = String(content.prefix(maxLen)) + "…" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + let ago = formatter.localizedString(for: message.timestamp, relativeTo: Date()) + return "<\(message.senderName)> \(content) · \(ago)" + } + + func narrationLine(_ text: String) -> some View { + emptyStateLine(text, color: palette.secondary.opacity(0.9)) + } + + /// Tappable lines render in the primary color so they read as actions + /// amid the grey narration. + func actionLine(_ text: String) -> some View { + emptyStateLine(text, color: palette.primary) + } + + func emptyStateLine(_ text: String, color: Color) -> some View { + // Non-breaking space before the closing asterisk so a tight wrap + // can't orphan a lone "*" onto its own line. + Text(verbatim: "* \(text)\u{00A0}*") + .bitchatFont(size: 13) + .foregroundColor(color) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/bitchat/Views/Components/MeshRadarView.swift b/bitchat/Views/Components/MeshRadarView.swift new file mode 100644 index 00000000..d59c569e --- /dev/null +++ b/bitchat/Views/Components/MeshRadarView.swift @@ -0,0 +1,68 @@ +// +// MeshRadarView.swift +// bitchat +// +// Ambient sonar shown on the empty mesh timeline: expanding rings around a +// center dot make it visible that the radio is broadcasting and scanning +// even when nobody is in range. Purely decorative — hidden from +// accessibility, static under Reduce Motion. +// This is free and unencumbered software released into the public domain. +// + +import SwiftUI + +struct MeshRadarView: View { + /// Full size on the empty timeline; the ambient footer under archived + /// echoes uses a smaller one. + var height: CGFloat = 72 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @ThemedPalette private var palette + + private let ringCount = 3 + private let period: TimeInterval = 3.0 + + var body: some View { + Group { + if reduceMotion { + radar(at: 0.35) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { context in + radar(at: context.date.timeIntervalSinceReferenceDate) + } + } + } + .frame(height: height) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + + private func radar(at time: TimeInterval) -> some View { + Canvas { context, size in + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let maxRadius = min(size.width, size.height) / 2 - 2 + + for ring in 0.. 1 else { continue } + let alpha = 0.45 * (1 - phase) + let rect = CGRect( + x: center.x - radius, + y: center.y - radius, + width: radius * 2, + height: radius * 2 + ) + context.stroke( + Path(ellipseIn: rect), + with: .color(palette.primary.opacity(alpha)), + lineWidth: 1 + ) + } + + let dot = CGRect(x: center.x - 2, y: center.y - 2, width: 4, height: 4) + context.fill(Path(ellipseIn: dot), with: .color(palette.primary.opacity(0.9))) + } + } +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 24af46f5..792e2870 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -23,12 +23,15 @@ struct ContentHeaderView: View { /// Unified notices sheet (board posts + location notes) for the current /// channel context. - @State private var showNotices = false /// Board posts mirrored from the store so the pin icon can show when the /// current scope has notices. @State private var boardPosts: [BoardPostPacket] = [] + /// Nostr-only location notes at this place (live while the empty mesh + /// timeline is showing) — they should light the pin too. + @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -157,11 +160,11 @@ struct ContentHeaderView: View { scopes.insert(geoScope) } boardAlertsModel.markSeen(forScopes: scopes) - showNotices = true + appChromeModel.presentNotices() }) { - // Fill marks unseen new pins; the tint says the current - // scope has notices at all. - Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin") + // Filled whenever the current scope has notices at all + // (matching the orange tint); hollow means nothing here. + Image(systemName: scopeHasNotices || unseenNoticesCount > 0 ? "pin.fill" : "pin") .font(.bitchatSystem(size: 12)) .foregroundColor( scopeHasNotices || unseenNoticesCount > 0 @@ -293,7 +296,10 @@ struct ContentHeaderView: View { .environmentObject(locationChannelsModel) .environmentObject(peerListModel) } - .sheet(isPresented: $showNotices) { + .sheet( + isPresented: $appChromeModel.isNoticesSheetPresented, + onDismiss: { appChromeModel.noticesSheetPrefersGeoTab = false } + ) { NoticesView( senderNickname: appChromeModel.nickname, board: appChromeModel.boardManager, @@ -334,8 +340,12 @@ private extension ContentHeaderView { } /// Open the notices sheet on the tab matching the current channel: the - /// geohash channel's notices, or the mesh-local board in mesh chat. + /// geohash channel's notices, or the mesh-local board in mesh chat. An + /// explicit geo-tab request (the "notes left here" hint) wins. var initialNoticesTab: NoticesView.Tab { + if appChromeModel.noticesSheetPrefersGeoTab { + return .geo + } if case .location = locationChannelsModel.selectedChannel { return .geo } @@ -351,9 +361,12 @@ private extension ContentHeaderView { return locationChannelsModel.currentBuildingGeohash } - /// Whether either tab of the notices sheet currently has content. + /// Whether either tab of the notices sheet currently has content: board + /// posts in scope, plus Nostr-only location notes when the nearby-notes + /// counter happens to be live (it runs with the empty mesh timeline). var scopeHasNotices: Bool { boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope } + || nearbyNotes.noteCount > 0 } /// New pins in either visible scope since the sheet was last opened. diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1506c1da..605599ea 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -19,6 +19,8 @@ struct MessageListView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var appChromeModel: AppChromeModel + @ObservedObject private var nearbyNotes = NearbyNotesCounter.shared @Environment(\.colorScheme) private var colorScheme @Environment(\.appTheme) private var theme @@ -45,6 +47,9 @@ struct MessageListView: View { /// switches swap the timeline wholesale, so a count delta is only a /// "new messages" signal while the context is unchanged. @State private var unseenBaselineKey = "" + /// Whether this instance holds the nearby-notes counter active (mesh + /// public timeline only); balanced against activate/deactivate. + @State private var holdsNotesCounter = false @ThemedPalette private var palette @@ -72,10 +77,19 @@ struct MessageListView: View { return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message) } + VStack(spacing: 0) { + // Notes pinned to this place stay visible while chatting — a + // conversation starting must not hide what's left here. + if privatePeer == nil, + case .mesh = locationChannelsModel.selectedChannel, + nearbyNotes.noteCount > 0 { + notesHereStrip + } + GeometryReader { geometry in ScrollViewReader { proxy in ScrollView { if messageItems.isEmpty && privatePeer == nil { - publicEmptyState + publicEmptyState(fillHeight: geometry.size.height) } LazyVStack(alignment: .leading, spacing: 0) { ForEach(messageItems) { item in @@ -150,10 +164,23 @@ struct MessageListView: View { } .padding(.horizontal, 12) .padding(.vertical, 1) + // Archived echoes read as one tinted block, not + // just faded rows. + .background(message.isArchivedEcho ? palette.secondary.opacity(0.08) : Color.clear) } } .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .padding(.vertical, 2) + + // Only carried history on screen: the ambient layer (radar, + // sightings, live hints) stays visible below it instead of + // vanishing the moment echoes exist. + if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) { + MeshEmptyStateView(compact: true) + .padding(.horizontal, 12) + .padding(.top, 20) + .padding(.bottom, 8) + } } .overlay(alignment: .bottomTrailing) { if !isAtBottom && !messageItems.isEmpty { @@ -246,6 +273,12 @@ struct MessageListView: View { scrollThrottleTimer?.invalidate() } } + } + } + .onAppear { updateNotesCounterHold() } + .onDisappear { releaseNotesCounterHold() } + .onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() } + .onChange(of: privatePeer) { _ in updateNotesCounterHold() } .environment(\.openURL, OpenURLAction { url in // Intercept custom cashu: links created in attributed text if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" { @@ -270,16 +303,75 @@ private extension MessageListView { return locationChannelsModel.selectedChannel.contextKey } + /// Tappable strip above the mesh timeline while notes are pinned at this + /// place: opens the notices sheet on the geo tab. + var notesHereStrip: some View { + let text: String = nearbyNotes.noteCount == 1 + ? String(localized: "content.empty.notes_one", comment: "Hint when exactly one note was left at this place") + : String( + format: String(localized: "content.empty.notes_many", comment: "Hint counting notes left at this place"), + locale: .current, + nearbyNotes.noteCount + ) + + return Button { + appChromeModel.presentNotices(geoTab: true) + } label: { + HStack(spacing: 6) { + Text(verbatim: "📍 \(text)") + .bitchatFont(size: 12) + .foregroundColor(palette.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.bitchatSystem(size: 10)) + .foregroundColor(palette.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(palette.secondary.opacity(0.08)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + /// The nearby-notes counter runs whenever the mesh public timeline is + /// showing — the strip needs a live count before it can decide to exist. + func updateNotesCounterHold() { + let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh + guard shouldHold != holdsNotesCounter else { return } + holdsNotesCounter = shouldHold + if shouldHold { + NearbyNotesCounter.shared.activate() + } else { + NearbyNotesCounter.shared.deactivate() + } + } + + func releaseNotesCounterHold() { + guard holdsNotesCounter else { return } + holdsNotesCounter = false + NearbyNotesCounter.shared.deactivate() + } + + /// True when the mesh timeline holds nothing but archived echoes and + /// system lines — no live conversation yet, so the ambient layer still + /// applies. + private func showsAmbientFooter(messageItems: [MessageDisplayItem]) -> Bool { + guard case .mesh = locationChannelsModel.selectedChannel, + !messageItems.isEmpty else { return false } + return messageItems.allSatisfy { $0.message.isArchivedEcho || $0.message.sender == "system" } + } + /// Terminal-styled narration for an empty public timeline: says which /// channel this is, that the app is waiting for peers, and where to go /// next. Rendered inside the ScrollView; disappears with the first row. - var publicEmptyState: some View { + /// The mesh case fills the visible chat height so its radar can center + /// in the space below the text. + func publicEmptyState(fillHeight: CGFloat) -> some View { VStack(alignment: .leading, spacing: 6) { switch locationChannelsModel.selectedChannel { case .mesh: - emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")) - emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet")) - emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")) + MeshEmptyStateView(fillHeight: max(0, fillHeight - 24)) case .location(let channel): emptyStateLine( String( @@ -410,6 +502,9 @@ private extension MessageListView { TextMessageView(message: message) } } + // Archived echoes ("heard here earlier") render dimmed: real history, + // visually distinct from the live conversation. + .opacity(message.isArchivedEcho ? 0.55 : 1) } @ViewBuilder diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift index 692158f1..a4ad3522 100644 --- a/bitchat/Views/NoticesView.swift +++ b/bitchat/Views/NoticesView.swift @@ -31,10 +31,18 @@ struct NoticesView: View { @State private var tab: Tab @State private var draft: String = "" @State private var urgent = false - @State private var expiryDays = 7 + /// Days until the notice fades; `permanentExpiry` (geo default) means no + /// NIP-40 tag — the note stays until its relay drops it. + @State private var expiryDays: Int + + /// Sentinel picker tag for the ∞ option (geo tab only). + private static let permanentExpiry = 0 /// Injected notes manager for tests; live use derives one per geohash. private let notesManager: LocationNotesManager? + /// Live manager owned by the sheet so the composer can post pure Nostr + /// notes (∞ expiry has no mesh-board copy) and the list can render them. + @State private var liveGeoManager: LocationNotesManager? init( senderNickname: String, @@ -46,6 +54,27 @@ struct NoticesView: View { self.board = board self.notesManager = notesManager _tab = State(initialValue: initialTab) + _expiryDays = State(initialValue: initialTab == .geo ? Self.permanentExpiry : 7) + } + + private var activeNotesManager: LocationNotesManager? { + notesManager ?? liveGeoManager + } + + /// Creates (or retargets/revives) the sheet-owned notes manager for the + /// current geo scope. + private func ensureGeoNotesManager() { + guard notesManager == nil, tab == .geo, let geohash = geoGeohash else { return } + if let manager = liveGeoManager { + if manager.geohash != geohash.lowercased() { + manager.setGeohash(geohash) + } else if manager.state == .idle { + // Cancelled on a tab switch; returning re-subscribes. + manager.refresh() + } + } else { + liveGeoManager = LocationNotesManager(geohash: geohash) + } } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } @@ -90,6 +119,7 @@ struct NoticesView: View { static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button") static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts") static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker") + static let permanentOption = String(localized: "notices.expiry.permanent", defaultValue: "permanent", comment: "Accessibility label for the ∞ (never expires) option in the geo notes expiry picker") static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button") static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh") static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays") @@ -109,6 +139,16 @@ struct NoticesView: View { ) } + static func fades(_ expiresAt: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return String( + format: String(localized: "notices.fades", defaultValue: "fades %@", comment: "Shown on notices with an expiry; placeholder is a localized relative time like 'in 23h'"), + locale: .current, + formatter.localizedString(for: expiresAt, relativeTo: Date()) + ) + } + static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String { let base = String( format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"), @@ -132,18 +172,29 @@ struct NoticesView: View { .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) #endif .themedSheetBackground() - .onAppear { beginGeoLocationIfNeeded() } + .onAppear { + beginGeoLocationIfNeeded() + ensureGeoNotesManager() + } .onChange(of: tab) { newTab in if newTab == .geo { beginGeoLocationIfNeeded() + ensureGeoNotesManager() } else { locationChannelsModel.endLiveRefresh() } + // Each tab keeps its natural default: geo notes stay until + // deleted (∞), mesh board posts fade within a week. + expiryDays = newTab == .geo ? Self.permanentExpiry : 7 + urgent = false } // Catches permission granted from the geo tab's enable button. .onChange(of: locationChannelsModel.permissionState) { _ in beginGeoLocationIfNeeded() } + .onChange(of: geoGeohash) { _ in + ensureGeoNotesManager() + } .onDisappear { locationChannelsModel.endLiveRefresh() } } @@ -207,7 +258,14 @@ struct NoticesView: View { ) case .geo: if let geohash = geoGeohash { - GeoNoticesList(geohash: geohash, board: board, manager: notesManager) + if let manager = activeNotesManager { + GeoNoticesList(geohash: geohash, board: board, manager: manager) + } else { + // Manager is created on appear; visible for one frame. + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { ensureGeoNotesManager() } + } } else { locationUnavailableSection } @@ -252,11 +310,10 @@ struct NoticesView: View { .disabled(!sendEnabled) .accessibilityLabel(Strings.send) } - // Urgency and expiry only travel with the mesh copy — the bridged - // Nostr note carries neither, so relay-side readers would never - // see them. Offer the controls only where they fully apply. - if tab == .mesh { - HStack(spacing: 12) { + // Both tabs pick an expiry (geo notes may be ∞); urgency is a + // mesh-board concept — notes are ambient by nature. + HStack(spacing: 12) { + if tab == .mesh { Toggle(isOn: $urgent) { Text(Strings.urgentToggle) .bitchatFont(size: 12) @@ -265,19 +322,30 @@ struct NoticesView: View { .toggleStyle(.switch) .fixedSize() .accessibilityLabel(Strings.urgentToggle) - Spacer() - Text(Strings.expiryLabel) - .bitchatFont(size: 12) - .foregroundColor(palette.secondary) - Picker(Strings.expiryLabel, selection: $expiryDays) { - ForEach([1, 3, 7], id: \.self) { days in - Text(Strings.expiryDaysOption(days)).tag(days) - } - } - .pickerStyle(.segmented) - .fixedSize() - .accessibilityLabel(Strings.expiryLabel) } + Spacer() + Text(Strings.expiryLabel) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Picker(Strings.expiryLabel, selection: $expiryDays) { + // Mesh board posts must fade (the wire caps their + // lifetime); only relay-backed geo notes can be ∞. + if tab == .geo { + Text(verbatim: "∞") + .accessibilityLabel(Strings.permanentOption) + .tag(Self.permanentExpiry) + } + ForEach([1, 3, 7], id: \.self) { days in + Text(Strings.expiryDaysOption(days)).tag(days) + } + } + .pickerStyle(.segmented) + // macOS segmented pickers render their own label; the themed + // Text alongside already carries it (and accessibility keeps + // the explicit label below). + .labelsHidden() + .fixedSize() + .accessibilityLabel(Strings.expiryLabel) } } .padding(.horizontal, 16) @@ -293,14 +361,27 @@ struct NoticesView: View { private func send() { guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return } - // Geo posts go to the board and are bridged to Nostr by BoardManager, - // so mesh and internet see the same notice. They always use the - // defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy). + + // ∞ (geo default): a pure relay note with no NIP-40 tag. It skips + // the mesh board deliberately — a board copy must fade within days, + // which would contradict the permanence the user just picked. + if tab == .geo, expiryDays == Self.permanentExpiry { + guard let manager = activeNotesManager else { return } + manager.send(content: content, nickname: senderNickname, expiresAt: nil) + draft = "" + urgent = false + return + } + + // Expiring posts go to the board and are bridged to Nostr by + // BoardManager, so mesh and internet see the same notice with the + // chosen expiry (expiresAt on mesh, NIP-40 on the bridged note). + // Urgency is mesh-only. let sent = board.createPost( content: content, geohash: geohash, urgent: tab == .mesh && urgent, - expiryDays: tab == .mesh ? expiryDays : 7, + expiryDays: expiryDays, nickname: senderNickname ) if sent { @@ -310,18 +391,19 @@ struct NoticesView: View { } } -/// The geo tab's list: owns the Nostr notes subscription for the scope -/// geohash and merges it with the board posts for the same geohash. +/// The geo tab's list: renders the sheet-owned Nostr notes subscription +/// merged with the board posts for the same geohash. The manager lives on +/// `NoticesView` so the composer can post through the same instance (∞ +/// notes local-echo into this list). private struct GeoNoticesList: View { let geohash: String @ObservedObject var board: BoardManager - @StateObject private var notesManager: LocationNotesManager + @ObservedObject var notesManager: LocationNotesManager - init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) { - let gh = geohash.lowercased() - self.geohash = gh + init(geohash: String, board: BoardManager, manager: LocationNotesManager) { + self.geohash = geohash.lowercased() self.board = board - _notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) + self.notesManager = manager } var body: some View { @@ -475,6 +557,11 @@ private struct NoticesList: View { Text(Self.timestampText(for: item.createdAt)) .bitchatFont(size: 11) .foregroundColor(palette.secondary) + if let expiresAt = item.expiresAt, expiresAt > Date() { + Text(Strings.fades(expiresAt)) + .bitchatFont(size: 11) + .foregroundColor(palette.secondary.opacity(0.8)) + } Spacer() if showsSource { sourceBadge(item) diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index e9c7549c..079da987 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -68,6 +68,13 @@ private final class MockChatPeerListContext: ChatPeerListContext { func notifyNetworkAvailable(peerCount: Int) { networkAvailableNotifications.append(peerCount) } + + // Sightings + private(set) var recordedSightings: [[PeerID]] = [] + + func recordMeshSightings(peerIDs: [PeerID]) { + recordedSightings.append(peerIDs) + } } // MARK: - Helpers diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index bf08fd84..896aca28 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -216,6 +216,126 @@ struct LocationNotesManagerTests { #expect(manager.errorMessage == nil) } + @Test + func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws { + var storedHandler: ((NostrEvent) -> Void)? + let now = Date(timeIntervalSince1970: 1_700_000_000) + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, _ in + storedHandler = handler + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { now } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + let identity = try NostrIdentity.generate() + + let expired = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now.addingTimeInterval(-3600), + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]], + content: "gone" + ) + storedHandler?(try expired.sign(with: identity.schnorrSigningKey())) + + let live = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: now.addingTimeInterval(-3600), + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]], + content: "still here" + ) + storedHandler?(try live.sign(with: identity.schnorrSigningKey())) + + #expect(manager.notes.count == 1) + #expect(manager.notes.first?.content == "still here") + #expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600))) + } + + @Test + func postDrop_sendsExpiringNoteToGeoRelays() throws { + var sentEvents: [NostrEvent] = [] + let now = Date(timeIntervalSince1970: 1_700_000_000) + let identity = try NostrIdentity.generate() + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { event, _ in sentEvents.append(event) }, + deriveIdentity: { _ in identity }, + now: { now } + ) + + let posted = LocationNotesManager.postDrop( + content: " the coffee here is great ", + nickname: "scout", + geohash: "u4pruydq", + dependencies: deps + ) + + #expect(posted) + #expect(sentEvents.count == 1) + let event = try #require(sentEvents.first) + #expect(event.kind == NostrProtocol.EventKind.textNote.rawValue) + #expect(event.content == "the coffee here is great") + #expect(event.tags.contains(["g", "u4pruydq"])) + let expiration = event.tags.first { $0.first == "expiration" }?.last + let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970) + #expect(expiration == String(expected)) + } + + @Test + func postDrop_failsWithoutRelays() { + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + #expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps)) + } + + @Test + func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws { + var storedHandler: ((NostrEvent) -> Void)? + var currentNow = Date(timeIntervalSince1970: 1_700_000_000) + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, handler, _ in + storedHandler = handler + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { currentNow } + ) + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + let identity = try NostrIdentity.generate() + let note = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: currentNow, + kind: .textNote, + tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]], + content: "short lived" + ) + storedHandler?(try note.sign(with: identity.schnorrSigningKey())) + #expect(manager.notes.count == 1) + + currentNow = currentNow.addingTimeInterval(120) + manager.pruneExpiredNotes() + + #expect(manager.notes.isEmpty) + } + private enum TestError: Error { case shouldNotDerive } diff --git a/bitchatTests/Services/GeohashChatActivityTrackerTests.swift b/bitchatTests/Services/GeohashChatActivityTrackerTests.swift new file mode 100644 index 00000000..a9786b14 --- /dev/null +++ b/bitchatTests/Services/GeohashChatActivityTrackerTests.swift @@ -0,0 +1,113 @@ +// +// GeohashChatActivityTrackerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat + +@MainActor +struct GeohashChatActivityTrackerTests { + + private let baseDate = Date(timeIntervalSince1970: 1_700_000_000) + + private func makeTracker(window: TimeInterval = 900, now: Date? = nil) -> (GeohashChatActivityTracker, (Date) -> Void) { + var currentNow = now ?? baseDate + let tracker = GeohashChatActivityTracker(window: window, now: { currentNow }) + return (tracker, { currentNow = $0 }) + } + + private func channel(_ geohash: String, _ level: GeohashChannelLevel) -> GeohashChannel { + GeohashChannel(level: level, geohash: geohash) + } + + @Test + func recordsAndCountsMessagesInWindow() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9Q8YY", senderName: "alice#ab12", content: "hi", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "bob#cd34", content: "yo", timestamp: baseDate) + + #expect(tracker.messageCount(for: "9q8yy") == 2) + #expect(tracker.lastMessage(for: "9q8YY")?.senderName == "bob#cd34") + } + + @Test + func dropsMessagesOlderThanWindow() { + let (tracker, advance) = makeTracker(window: 900) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "alice#ab12", content: "hi", timestamp: baseDate) + + advance(baseDate.addingTimeInterval(901)) + + #expect(tracker.messageCount(for: "9q8yy") == 0) + #expect(tracker.lastMessage(for: "9q8yy") == nil) + } + + @Test + func ignoresMessagesAlreadyOutsideWindow() { + let (tracker, _) = makeTracker(window: 900) + tracker.recordChatMessage( + geohash: "9q8yy", + senderName: "alice#ab12", + content: "old", + timestamp: baseDate.addingTimeInterval(-1000) + ) + + #expect(tracker.messageCount(for: "9q8yy") == 0) + } + + @Test + func keepsNewestPreview() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "newer", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8yy", senderName: "b#2222", content: "older", timestamp: baseDate.addingTimeInterval(-60)) + + #expect(tracker.lastMessage(for: "9q8yy")?.content == "newer") + #expect(tracker.messageCount(for: "9q8yy") == 2) + } + + @Test + func mostActivePicksBusiestChannel() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "one", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8", senderName: "b#2222", content: "two", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q8", senderName: "c#3333", content: "three", timestamp: baseDate) + + let channels = [channel("9q8yy", .city), channel("9q8", .province)] + let best = tracker.mostActiveConversation(among: channels) + + #expect(best?.channel.geohash == "9q8") + #expect(best?.messageCount == 2) + } + + @Test + func mostActiveTieGoesToMoreLocalChannel() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yyzz1", senderName: "a#1111", content: "local", timestamp: baseDate) + tracker.recordChatMessage(geohash: "9q", senderName: "b#2222", content: "regional", timestamp: baseDate) + + let channels = [channel("9q", .region), channel("9q8yyzz1", .building)] + let best = tracker.mostActiveConversation(among: channels) + + #expect(best?.channel.geohash == "9q8yyzz1") + } + + @Test + func mostActiveIsNilWithoutMessages() { + let (tracker, _) = makeTracker() + #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) + } + + @Test + func clearRemovesEverything() { + let (tracker, _) = makeTracker() + tracker.recordChatMessage(geohash: "9q8yy", senderName: "a#1111", content: "hi", timestamp: baseDate) + tracker.clear() + + #expect(tracker.messageCount(for: "9q8yy") == 0) + #expect(tracker.mostActiveConversation(among: [channel("9q8yy", .city)]) == nil) + } +} diff --git a/bitchatTests/Services/MeshSightingsTrackerTests.swift b/bitchatTests/Services/MeshSightingsTrackerTests.swift new file mode 100644 index 00000000..e6795e7b --- /dev/null +++ b/bitchatTests/Services/MeshSightingsTrackerTests.swift @@ -0,0 +1,81 @@ +// +// MeshSightingsTrackerTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import bitchat +import BitFoundation + +@MainActor +struct MeshSightingsTrackerTests { + + private func makeDefaults() -> UserDefaults { + let suite = "MeshSightingsTrackerTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + private let noon = Date(timeIntervalSince1970: 1_700_000_000) + + @Test + func countsDistinctPeersOnce() { + let defaults = makeDefaults() + let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) + + #expect(tracker.todayCount == 2) + #expect(tracker.lastSightingAt == noon) + } + + @Test + func persistsAcrossInstances() { + let defaults = makeDefaults() + let first = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + first.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + + let second = MeshSightingsTracker(defaults: defaults, now: { self.noon.addingTimeInterval(60) }) + #expect(second.todayCount == 1) + + // Same peer again does not double count after a relaunch. + second.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + #expect(second.todayCount == 1) + } + + @Test + func rollsOverOnNewDay() { + let defaults = makeDefaults() + var currentNow = noon + let tracker = MeshSightingsTracker(defaults: defaults, now: { currentNow }) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + #expect(tracker.todayCount == 1) + + currentNow = noon.addingTimeInterval(2 * 24 * 60 * 60) + tracker.recordSighting(peerID: PeerID(str: "bbbb444455556666")) + + #expect(tracker.todayCount == 1) + } + + @Test + func clearResetsEverything() { + let defaults = makeDefaults() + let tracker = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + tracker.recordSighting(peerID: PeerID(str: "aaaa111122223333")) + + tracker.clear() + + #expect(tracker.todayCount == 0) + #expect(tracker.lastSightingAt == nil) + + let reloaded = MeshSightingsTracker(defaults: defaults, now: { self.noon }) + #expect(reloaded.todayCount == 0) + } +} diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index debb41e5..b33c905b 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -415,7 +415,7 @@ struct ViewSmokeTests { let board = BoardManager( transport: transport, store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }), - publishToNostr: { _, _, _, _ in nil }, + publishToNostr: { _, _, _, _, _ in nil }, deleteFromNostr: { _, _ in } )