diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index d3d72b33..8cbbf031 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -35701,6 +35701,544 @@ } } } + }, + "Voice notes are only available in mesh chats." : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الملاحظات الصوتية متاحة فقط في محادثات الميش." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ভয়েস নোট শুধু মেশ চ্যাটে উপলব্ধ।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprachnachrichten sind nur im Mesh-Chat verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voice notes are only available in mesh chats." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las notas de voz solo están disponibles en los chats de mesh." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ang mga voice note ay available lamang sa mga mesh chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les notes vocales sont uniquement disponibles dans les discussions mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הערות קוליות זמינות רק בצ׳אט של mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "वॉइस नोट्स केवल मेश चैट में ही उपलब्ध हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Catatan suara hanya tersedia di obrolan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le note vocali sono disponibili solo nelle chat mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボイスメモはメッシュチャットでのみ利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "음성 메모는 메쉬 채팅에서만 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nota suara hanya tersedia dalam sembang mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "भ्वाइस नोटहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spraaknotities zijn alleen beschikbaar in mesh-chats." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notatki głosowe są dostępne tylko na czatach mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "As notas de voz só estão disponíveis nos chats mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As mensagens de voz só estão disponíveis nos chats mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Голосовые сообщения доступны только в mesh-чатах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Röstanteckningar är bara tillgängliga i mesh-chattar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "குரல் குறிப்புகள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "บันทึกเสียงใช้งานได้เฉพาะในแชต mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesli notlar yalnızca mesh sohbetlerinde kullanılabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Голосові нотатки доступні лише в mesh-чатах." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "وائس نوٹس صرف میش چیٹس میں دستیاب ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi chú giọng nói chỉ khả dụng trong các cuộc trò chuyện mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语音消息仅可在 mesh 聊天中使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "語音訊息僅能在 mesh 聊天中使用。" + } + } + } + }, + "Images are only available in mesh chats." : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الصور متاحة فقط في محادثات الميش." + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ছবি শুধু মেশ চ্যাটে উপলব্ধ।" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder sind nur im Mesh-Chat verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Images are only available in mesh chats." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las imágenes solo están disponibles en los chats de mesh." + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ang mga larawan ay available lamang sa mga mesh chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les images sont uniquement disponibles dans les discussions mesh." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תמונות זמינות רק בצ׳אט של mesh." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "चित्र केवल मेश चैट में ही उपलब्ध हैं।" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gambar hanya tersedia di obrolan mesh." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le immagini sono disponibili solo nelle chat mesh." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像はメッシュチャットでのみ利用できます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지는 메쉬 채팅에서만 사용할 수 있습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imej hanya tersedia dalam sembang mesh." + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "तस्बिरहरू केवल मेष च्याटमा मात्र उपलब्ध छन्।" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeeldingen zijn alleen beschikbaar in mesh-chats." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obrazy są dostępne tylko na czatach mesh." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "As imagens só estão disponíveis nos chats mesh." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изображения доступны только в mesh-чатах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder är bara tillgängliga i mesh-chattar." + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "படங்கள் மெஷ் உரையாடல்களில் மட்டுமே கிடைக்கும்." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "รูปภาพใช้งานได้เฉพาะในแชต mesh เท่านั้น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görseller yalnızca mesh sohbetlerinde kullanılabilir." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зображення доступні лише в mesh-чатах." + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصاویر صرف میش چیٹس میں دستیاب ہیں۔" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hình ảnh chỉ khả dụng trong các cuộc trò chuyện mesh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图片仅可在 mesh 聊天中使用。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "圖片僅能在 mesh 聊天中使用。" + } + } + } + }, + "Choose an image" : { + "comment" : "A label displayed above a button that allows the user to choose an image to send.", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "اختر صورة" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "একটি ছবি নির্বাচন করুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild auswählen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an image" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige una imagen" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumili ng larawan" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisir une image" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בחר תמונה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एक चित्र चुनें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih gambar" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un’immagine" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を選択" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지를 선택하세요" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilih imej" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "एउटा तस्वीर चयन गर्नुहोस्" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een afbeelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz obraz" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolher uma imagem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha uma imagem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите изображение" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en bild" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ஒரு படத்தைத் தேர்ந்தெடுக்கவும்" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "เลือกภาพ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir görüntü seç" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виберіть зображення" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ایک تصویر منتخب کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chọn một hình ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择图像" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "選擇圖像" + } + } + } } }, "version" : "1.1" diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift index a0dd0afd..490123d0 100644 --- a/bitchat/Nostr/GeoRelayDirectory.swift +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -1,6 +1,11 @@ import BitLogger import Foundation import Tor +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. @MainActor @@ -12,19 +17,32 @@ final class GeoRelayDirectory { } static let shared = GeoRelayDirectory() + private(set) var entries: [Entry] = [] private let cacheFileName = "georelays_cache.csv" private let lastFetchKey = "georelay.lastFetchAt" private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! - private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h + private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds + + private var refreshTimer: Timer? + private var retryTask: Task? + private var retryAttempt: Int = 0 + private var isFetching: Bool = false + private var observers: [NSObjectProtocol] = [] private init() { - // Load cached or bundled data synchronously - self.entries = self.loadLocalEntries() - // Fire-and-forget remote refresh if stale + entries = loadLocalEntries() + registerObservers() + startRefreshTimer() prefetchIfNeeded() } + deinit { + observers.forEach { NotificationCenter.default.removeObserver($0) } + refreshTimer?.invalidate() + retryTask?.cancel() + } + /// Returns up to `count` relay URLs (wss://) closest to the geohash center. func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { let center = Geohash.decodeCenter(geohash) @@ -62,42 +80,119 @@ final class GeoRelayDirectory { } // MARK: - Remote Fetch - func prefetchIfNeeded() { + func prefetchIfNeeded(force: Bool = false) { + guard !isFetching else { return } + let now = Date() let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast - guard now.timeIntervalSince(last) >= fetchInterval else { return } + + if !force { + guard now.timeIntervalSince(last) >= fetchInterval else { return } + } else if last != .distantPast, + now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds { + // Skip forced fetches if we just refreshed moments ago. + return + } + + cancelRetry() fetchRemote() } private func fetchRemote() { - let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15) - // Ensure Tor readiness before fetching (fail-closed by default) - Task.detached { + guard !isFetching else { return } + isFetching = true + + let request = URLRequest( + url: remoteURL, + cachePolicy: .reloadIgnoringLocalCacheData, + timeoutInterval: 15 + ) + + Task.detached { [weak self] in + guard let self else { return } + let ready = await TorManager.shared.awaitReady() if !ready { - SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session) + await self.handleFetchFailure(.torNotReady) return } - let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in - guard let self = self else { return } - if let data = data, error == nil, let text = String(data: data, encoding: .utf8) { - let parsed = GeoRelayDirectory.parseCSV(text) - if !parsed.isEmpty { - Task { @MainActor in - self.entries = parsed - self.persistCache(text) - UserDefaults.standard.set(Date(), forKey: self.lastFetchKey) - SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) - } - return - } + + do { + let (data, _) = try await TorURLSession.shared.session.data(for: request) + guard let text = String(data: data, encoding: .utf8) else { + await self.handleFetchFailure(.invalidData) + return } - SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session) + + let parsed = GeoRelayDirectory.parseCSV(text) + guard !parsed.isEmpty else { + await self.handleFetchFailure(.invalidData) + return + } + + await self.handleFetchSuccess(entries: parsed, csv: text) + } catch { + await self.handleFetchFailure(.network(error)) } - task.resume() } } + private enum FetchFailure { + case torNotReady + case invalidData + case network(Error) + } + + @MainActor + private func handleFetchSuccess(entries parsed: [Entry], csv: String) { + entries = parsed + persistCache(csv) + UserDefaults.standard.set(Date(), forKey: lastFetchKey) + SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) + isFetching = false + retryAttempt = 0 + cancelRetry() + } + + @MainActor + private func handleFetchFailure(_ reason: FetchFailure) { + switch reason { + case .torNotReady: + SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session) + case .invalidData: + SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session) + case .network(let error): + SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session) + } + isFetching = false + scheduleRetry() + } + + @MainActor + private func scheduleRetry() { + retryAttempt = min(retryAttempt + 1, 10) + let base = TransportConfig.geoRelayRetryInitialSeconds + let maxDelay = TransportConfig.geoRelayRetryMaxSeconds + let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0))) + let calculated = base * multiplier + let delay = min(maxDelay, max(base, calculated)) + + cancelRetry() + retryTask = Task { [weak self] in + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + await MainActor.run { + self?.prefetchIfNeeded(force: true) + } + } + } + + @MainActor + private func cancelRetry() { + retryTask?.cancel() + retryTask = nil + } + private func persistCache(_ text: String) { guard let url = cacheURL() else { return } do { @@ -110,30 +205,35 @@ final class GeoRelayDirectory { // MARK: - Loading private func loadLocalEntries() -> [Entry] { // Prefer cached file if present - if let cache = self.cacheURL(), + if let cache = cacheURL(), let data = try? Data(contentsOf: cache), let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } } + // Try bundled resource(s) let bundleCandidates = [ Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"), Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"), Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays") ].compactMap { $0 } + for url in bundleCandidates { - if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { + if let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) { let arr = Self.parseCSV(text) if !arr.isEmpty { return arr } } } + // Try filesystem path (development/test) if let cwd = FileManager.default.currentDirectoryPath as String?, let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let text = String(data: data, encoding: .utf8) { return Self.parseCSV(text) } + SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) return [] } @@ -141,7 +241,6 @@ final class GeoRelayDirectory { nonisolated static func parseCSV(_ text: String) -> [Entry] { var result: Set = [] let lines = text.split(whereSeparator: { $0.isNewline }) - // Skip header if present for (idx, raw) in lines.enumerated() { let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) if line.isEmpty { continue } @@ -162,11 +261,67 @@ final class GeoRelayDirectory { private func cacheURL() -> URL? { do { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) let dir = base.appendingPathComponent("bitchat", isDirectory: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir.appendingPathComponent(cacheFileName) - } catch { return nil } + } catch { + return nil + } + } + + // MARK: - Observers & Timers + private func registerObservers() { + let center = NotificationCenter.default + + let torReady = center.addObserver( + forName: .TorDidBecomeReady, + object: nil, + queue: .main + ) { [weak self] _ in + self?.prefetchIfNeeded(force: true) + } + observers.append(torReady) + +#if os(iOS) + let didBecomeActive = center.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.prefetchIfNeeded() + } + observers.append(didBecomeActive) +#elseif os(macOS) + let didBecomeActive = center.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.prefetchIfNeeded() + } + observers.append(didBecomeActive) +#endif + } + + private func startRefreshTimer() { + refreshTimer?.invalidate() + let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds + guard interval > 0 else { return } + + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + guard let self else { return } + Task { @MainActor in + self.prefetchIfNeeded() + } + } + refreshTimer = timer + RunLoop.main.add(timer, forMode: .common) } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 5702fd55..09439402 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -145,6 +145,9 @@ enum TransportConfig { // Geo relay directory static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 + static let geoRelayRefreshCheckIntervalSeconds: TimeInterval = 60 * 60 + static let geoRelayRetryInitialSeconds: TimeInterval = 60 + static let geoRelayRetryMaxSeconds: TimeInterval = 60 * 60 // BLE operational delays static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6 diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index f94ffd1a..da8ef964 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -145,6 +145,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } + private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool) + + @MainActor + private var canSendMediaInCurrentContext: Bool { + if let peer = selectedPrivateChatPeer { + return !(peer.isGeoDM || peer.isGeoChat) + } + switch activeChannel { + case .mesh: return true + case .location: return false + } + } + private var rateBucketsBySender: [String: TokenBucket] = [:] private var rateBucketsByContent: [String: TokenBucket] = [:] private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity @@ -1443,20 +1456,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Parse mentions from the content (use original content for user intent) let mentions = parseMentions(from: content) + var geoContext: GeoOutgoingContext? = nil + // Add message to local display var displaySender = nickname var localSenderPeerID = meshService.myPeerID - if case .location(let ch) = activeChannel, - let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let suffix = String(myGeoIdentity.publicKeyHex.suffix(4)) - displaySender = nickname + "#" + suffix - localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex) + var messageID: String? = nil + var messageTimestamp = Date() + + switch activeChannel { + case .mesh: + break + case .location(let ch): + do { + let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = nickname + "#" + suffix + localSenderPeerID = PeerID(nostr: identity.publicKeyHex) + let teleported = LocationChannelManager.shared.teleported + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: trimmed, + geohash: ch.geohash, + senderIdentity: identity, + nickname: nickname, + teleported: teleported + ) + messageID = event.id + messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + geoContext = (channel: ch, event: event, identity: identity, teleported: teleported) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } } let message = BitchatMessage( + id: messageID, sender: displaySender, content: trimmed, - timestamp: Date(), + timestamp: messageTimestamp, isRelay: false, senderPeerID: localSenderPeerID, mentions: mentions.isEmpty ? nil : mentions @@ -1487,10 +1528,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // UI updates automatically via @Published var messages - updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions) + updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions, geoContext: geoContext) } - private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String]) { + private func updateChannelActivityTimeThenSend(content: String, trimmed: String, mentions: [String], geoContext: GeoOutgoingContext?) { switch activeChannel { case .mesh: lastPublicActivityAt["mesh"] = Date() @@ -1498,58 +1539,54 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { meshService.sendMessage(content, mentions: mentions) case .location(let ch): lastPublicActivityAt["geo:\(ch.geohash)"] = Date() + guard let context = geoContext, context.channel.geohash == ch.geohash else { + SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session) + addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } // Send to geohash channel via Nostr ephemeral Task { @MainActor in - sendGeohash(ch: ch, content: trimmed) + self.sendGeohash(context: context) } } } @MainActor - private func sendGeohash(ch: GeohashChannel, content: String) { - do { - let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) + private func sendGeohash(context: GeoOutgoingContext) { + let ch = context.channel + let event = context.event + let identity = context.identity - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: content, - geohash: ch.geohash, - senderIdentity: identity, - nickname: nickname, - teleported: LocationChannelManager.shared.teleported - ) - - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: ch.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: ch.geohash, + count: TransportConfig.nostrGeoRelayCount + ) - // Track ourselves as active participant - recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex - SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", category: .session) - - // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI - // Only when not in our regional set (and regional list is known) - let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } - - if LocationChannelManager.shared.teleported && hasRegional && !inRegional { - let key = identity.publicKeyHex.lowercased() - teleportedGeo = teleportedGeo.union([key]) - SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) - } - } catch { - SecureLogger.error("❌ Failed to send geohash message: \(error)", category: .session) - addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) } + + // Track ourselves as active participant + recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex + SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) + + // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI + // Only when not in our regional set (and regional list is known) + let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty + let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } + + if context.teleported && hasRegional && !inRegional { + let key = identity.publicKeyHex.lowercased() + teleportedGeo = teleportedGeo.union([key]) + SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) + } + + recordProcessedEvent(event.id) } @MainActor @@ -2407,6 +2444,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func sendVoiceNote(at url: URL) { + guard canSendMediaInCurrentContext else { + SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) + try? FileManager.default.removeItem(at: url) + addSystemMessage("Voice notes are only available in mesh chats.") + return + } + let targetPeer = selectedPrivateChatPeer let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer?.id) let messageID = message.id @@ -2455,6 +2499,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { + guard canSendMediaInCurrentContext else { + SecureLogger.info("Image send blocked outside mesh/private context", category: .session) + cleanup?() + addSystemMessage("Images are only available in mesh chats.") + return + } + let targetPeer = selectedPrivateChatPeer Task.detached(priority: .userInitiated) { [weak self] in diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e8c49020..4be6cd54 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1806,7 +1806,19 @@ private extension ContentView { } private var shouldShowMediaControls: Bool { - if viewModel.selectedPrivateChatPeer != nil { + if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { + return true + } + switch locationManager.selectedChannel { + case .mesh: + return true + case .location: + return false + } + } + + private var shouldShowVoiceControl: Bool { + if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { return true } switch locationManager.selectedChannel { @@ -1851,17 +1863,23 @@ private extension ContentView { #endif } + @ViewBuilder var sendOrMicButton: some View { let hasText = !trimmedMessageText.isEmpty - return ZStack { - micButtonView - .opacity(hasText ? 0 : 1) - .allowsHitTesting(!hasText) + if shouldShowVoiceControl { + ZStack { + micButtonView + .opacity(hasText ? 0 : 1) + .allowsHitTesting(!hasText) + sendButtonView(enabled: hasText) + .opacity(hasText ? 1 : 0) + .allowsHitTesting(hasText) + } + .frame(width: 36, height: 36) + } else { sendButtonView(enabled: hasText) - .opacity(hasText ? 1 : 0) - .allowsHitTesting(hasText) + .frame(width: 36, height: 36) } - .frame(width: 36, height: 36) } private var micButtonView: some View { @@ -1914,6 +1932,7 @@ private extension ContentView { } func startVoiceRecording() { + guard shouldShowVoiceControl else { return } guard !isRecordingVoiceNote && !isPreparingVoiceNote else { return } isPreparingVoiceNote = true Task { @MainActor in