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 } )