diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index a5c93c76..6fb76002 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -46427,6 +46427,186 @@ } } }, + "location_notes.connecting_relays" : { + "comment" : "Status line while geo notes wait for a relay connection (e.g. Tor still bootstrapping)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جارٍ الاتصال بالمرحّلات…" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "রিলেতে সংযোগ হচ্ছে…" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbinde mit relays…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectando a los relays…" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "kumokonekta sa mga relay…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "connexion aux relais…" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתחבר לממסרים…" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिले से कनेक्ट हो रहा है…" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "menghubungkan ke relay…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "connessione ai relay…" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リレーに接続中…" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "릴레이에 연결 중…" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "menyambung ke relay…" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "रिलेमा जडान हुँदैछ…" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "verbinden met relays…" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "łączenie z relay…" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "a ligar aos relés…" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "conectando aos relés…" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "подключение к релеям…" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "ansluter till reläer…" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "ரிலேயுடன் இணைக்கிறது…" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กำลังเชื่อมต่อรีเลย์…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "relay'lere bağlanılıyor…" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "підключення до релеїв…" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "ریلے سے منسلک ہو رہا ہے…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "đang kết nối đến relay…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在连接中继…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在連接中繼…" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "connecting to relays…" + } + } + } + }, "location_notes.description" : { "extractionState" : "manual", "localizations" : { diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 004ba5f3..63583cc7 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -183,11 +183,26 @@ final class NostrRelayManager: ObservableObject { } private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] - // Track EOSE per subscription to signal when initial stored events are done + // Track EOSE per subscription to signal when initial stored events are + // done. Completion is scoped to relays the REQ actually reached: targets + // still mid-connect must not hold the callback hostage until the fallback + // timer (a dead relay of five used to pin "loading" for the full 10s). private struct EOSETracker { - var pendingRelays: Set + /// Targets the REQ has not been delivered to yet (still connecting). + var awaitingSend: Set + /// Relays that received the REQ and have not sent EOSE yet. + var awaitingEOSE: Set + /// True once any relay received the REQ (or answered with EOSE) — + /// completion with zero sends would mean "done" without ever asking. + var didSend = false var callback: () -> Void let epoch: Int + + /// Done when every relay that got the REQ has resolved, provided at + /// least one did — or when every target dropped out entirely. + var isComplete: Bool { + (didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty) + } } private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackerEpoch = 0 @@ -793,7 +808,7 @@ final class NostrRelayManager: ObservableObject { private func startEOSETracking(id: String, relayURLs: Set, callback: @escaping () -> Void) { eoseTrackerEpoch += 1 let epoch = eoseTrackerEpoch - eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch) + eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch) // Fallback timeout to avoid hanging if a relay never sends EOSE. dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in Task { @MainActor [weak self] in @@ -933,8 +948,19 @@ final class NostrRelayManager: ObservableObject { toSend[id] = state.messageString } for (id, messageString) in toSend { - if self.subscriptions[relayUrl]?.contains(id) == true { continue } + if self.subscriptions[relayUrl]?.contains(id) == true { + // Already subscribed on this relay (e.g. a tracker promoted + // after an earlier flush): its EOSE is coming, count it. + markEOSESubscribed(id: id, relayUrl: relayUrl) + continue + } startPendingEOSETrackingIfNeeded(id: id) + // Mark at send *initiation*, not in the async completion: a fast + // relay's EOSE could otherwise complete the tracker while this + // relay — REQ already on the wire — still sat in awaitingSend. + // If the send fails the socket is going down with it, and the + // disconnect settle (or the fallback timer) releases the wait. + markEOSESubscribed(id: id, relayUrl: relayUrl) connection.send(.string(messageString)) { [weak self, weak connection] error in Task { @MainActor [weak self] in guard let self else { return } @@ -1016,8 +1042,12 @@ final class NostrRelayManager: ObservableObject { } case .eose(let subId): if var tracker = eoseTrackers[subId] { - tracker.pendingRelays.remove(relayUrl) - if tracker.pendingRelays.isEmpty { + // An EOSE proves the relay received the REQ even if the local + // send completion hasn't run yet. + tracker.awaitingSend.remove(relayUrl) + tracker.awaitingEOSE.remove(relayUrl) + tracker.didSend = true + if tracker.isComplete { eoseTrackers.removeValue(forKey: subId) tracker.callback() } else { @@ -1097,9 +1127,11 @@ final class NostrRelayManager: ObservableObject { /// callbacks; treat it as done and let the remaining relays (or the /// fallback timeout) drive completion. private func settleEOSETrackers(droppingRelay relayUrl: String) { - for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) { - tracker.pendingRelays.remove(relayUrl) - if tracker.pendingRelays.isEmpty { + for (id, var tracker) in eoseTrackers + where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) { + tracker.awaitingSend.remove(relayUrl) + tracker.awaitingEOSE.remove(relayUrl) + if tracker.isComplete { eoseTrackers.removeValue(forKey: id) tracker.callback() } else { @@ -1108,6 +1140,24 @@ final class NostrRelayManager: ObservableObject { } } + /// Whether any of `relayUrls` currently holds a live connection. Lets + /// subscribers distinguish "loaded, empty" from "never reached a relay" + /// when an EOSE fallback fires. + func isAnyRelayConnected(among relayUrls: [String]) -> Bool { + let targets = Set(relayUrls) + return relays.contains { targets.contains($0.url) && $0.isConnected } + } + + /// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on + /// this relay instead of the never-connected remainder. + private func markEOSESubscribed(id: String, relayUrl: String) { + guard var tracker = eoseTrackers[id], + tracker.awaitingSend.remove(relayUrl) != nil else { return } + tracker.awaitingEOSE.insert(relayUrl) + tracker.didSend = true + eoseTrackers[id] = tracker + } + private func handleDisconnection(relayUrl: String, error: Error) { connections.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl) diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 4b5577a1..a85a32bc 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -17,6 +17,10 @@ struct LocationNotesDependencies { var now: () -> Date // Fires when the geo relay directory refreshes; used to retry after "no relays". var relayDirectoryUpdates: AnyPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() + /// Whether any of the target relays has a live connection — distinguishes + /// "loaded, empty" from "still connecting (Tor warming up)" when EOSE + /// fires without data. Defaults to true so tests keep legacy behavior. + var anyRelayConnected: @MainActor (_ relayUrls: [String]) -> Bool = { _ in true } private static let idBridge = NostrIdentityBridge() @@ -46,7 +50,10 @@ struct LocationNotesDependencies { relayDirectoryUpdates: NotificationCenter.default .publisher(for: .geoRelayDirectoryDidRefresh) .map { _ in () } - .eraseToAnyPublisher() + .eraseToAnyPublisher(), + anyRelayConnected: { relayUrls in + NostrRelayManager.shared.isAnyRelayConnected(among: relayUrls) + } ) } @@ -57,6 +64,10 @@ final class LocationNotesManager: ObservableObject { enum State: Equatable { case idle case loading + /// The initial fetch timed out with zero target relays connected + /// (usually Tor still bootstrapping): not "empty", just not there + /// yet. Retries automatically once a relay comes up. + case connecting case ready case noRelays } @@ -115,6 +126,7 @@ final class LocationNotesManager: ObservableObject { private var noteIDs = Set() // O(1) duplicate detection private var directoryUpdateCancellable: AnyCancellable? private var expiryPruneTimer: Timer? + private var connectivityRetryTimer: Timer? private let dependencies: LocationNotesDependencies private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200) @@ -160,6 +172,7 @@ final class LocationNotesManager: ObservableObject { deinit { expiryPruneTimer?.invalidate() + connectivityRetryTimer?.invalidate() } /// Drops notes whose NIP-40 expiry has passed. Their ids stay in @@ -220,6 +233,8 @@ final class LocationNotesManager: ObservableObject { private func subscribe() { state = .loading errorMessage = nil + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil if let sub = subscriptionID { dependencies.unsubscribe(sub) subscriptionID = nil @@ -270,12 +285,47 @@ final class LocationNotesManager: ObservableObject { }, { [weak self] in guard let self = self else { return } self.initialLoadComplete = true - if self.state != .noRelays { + guard self.state != .noRelays else { return } + // EOSE with no data and zero connected target relays means the + // 10s fallback fired while Tor was still warming up — showing + // "no notes" would be a lie. Wait visibly and retry. + if self.notes.isEmpty, !self.dependencies.anyRelayConnected(relays) { + self.state = .connecting + self.scheduleConnectivityRetry(relays: relays) + } else { self.state = .ready } }) } + /// While `.connecting`, poll for a live target relay and re-subscribe as + /// soon as one appears (fresh REQ, fresh EOSE tracking). The poll dies + /// with the state: any subscribe/cancel invalidates it. + private func scheduleConnectivityRetry(relays: [String]) { + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = Timer.scheduledTimer( + withTimeInterval: TransportConfig.uiGeoNotesConnectivityRetrySeconds, + repeats: true + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.retryIfRelaysAvailable(relays: relays) + } + } + } + + func retryIfRelaysAvailable(relays: [String]) { + guard state == .connecting else { + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil + return + } + guard dependencies.anyRelayConnected(relays) else { return } + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil + SecureLogger.debug("LocationNotesManager: relay came up, retrying notes fetch for \(geohash)", category: .session) + refresh() + } + /// 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. @@ -410,6 +460,8 @@ final class LocationNotesManager: ObservableObject { dependencies.unsubscribe(sub) subscriptionID = nil } + connectivityRetryTimer?.invalidate() + connectivityRetryTimer = nil state = .idle errorMessage = nil } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index b86791be..ac42aa9f 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -180,6 +180,9 @@ enum TransportConfig { static let uiArchivedEchoLoadDelaySeconds: TimeInterval = 1.5 // Dead drops: location notes left via /drop expire after this long. static let locationDropExpirySeconds: TimeInterval = 24 * 60 * 60 + // Poll cadence while geo notes wait for a relay connection (Tor warming + // up); re-subscribes as soon as one comes up. + static let uiGeoNotesConnectivityRetrySeconds: TimeInterval = 3.0 // Message deduplication static let messageDedupMaxAgeSeconds: TimeInterval = 300 diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift index a4ad3522..7f133709 100644 --- a/bitchat/Views/NoticesView.swift +++ b/bitchat/Views/NoticesView.swift @@ -126,6 +126,7 @@ struct NoticesView: View { static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices") static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices") static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes" + static let connectingRelays: LocalizedStringKey = "location_notes.connecting_relays" static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby" static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint" static let retry: LocalizedStringKey = "location_notes.action.retry" @@ -476,7 +477,9 @@ private struct NoticesList: View { /// once the sources settled. private var showEmptyState: Bool { guard let notesManager else { return true } - return notesManager.initialLoadComplete && notesManager.state != .loading + return notesManager.initialLoadComplete + && notesManager.state != .loading + && notesManager.state != .connecting } @ViewBuilder @@ -491,6 +494,15 @@ private struct NoticesList: View { Spacer() } .padding(.vertical, 8) + } else if notesManager.state == .connecting { + HStack(spacing: 10) { + ProgressView() + Text(Strings.connectingRelays) + .bitchatFont(size: 12) + .foregroundColor(palette.secondary) + Spacer() + } + .padding(.vertical, 8) } else if notesManager.state == .noRelays { VStack(alignment: .leading, spacing: 4) { Text(Strings.noRelaysNearby) diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index 896aca28..80b8713d 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -336,6 +336,79 @@ struct LocationNotesManagerTests { #expect(manager.notes.isEmpty) } + @Test + func eoseWithoutConnectedRelays_showsConnectingInsteadOfEmpty() { + var storedEOSE: (() -> Void)? + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in storedEOSE = eose }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in false } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + storedEOSE?() + + #expect(manager.state == .connecting) + #expect(manager.initialLoadComplete) + } + + @Test + func eoseWithConnectedRelayAndNoNotes_isReadyEmpty() { + var storedEOSE: (() -> Void)? + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in storedEOSE = eose }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in true } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + storedEOSE?() + + #expect(manager.state == .ready) + } + + @Test + func connectingState_retriesOnceARelayComesUp() { + var storedEOSE: (() -> Void)? + var subscribeCount = 0 + var relayUp = false + var deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { _, _, _, _, eose in + subscribeCount += 1 + storedEOSE = eose + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + deps.anyRelayConnected = { _ in relayUp } + + let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) + #expect(subscribeCount == 1) + storedEOSE?() + #expect(manager.state == .connecting) + + // Relay still down: no retry. + manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) + #expect(subscribeCount == 1) + + // Relay up: re-subscribes for a fresh initial fetch. + relayUp = true + manager.retryIfRelaysAvailable(relays: ["wss://relay.one"]) + #expect(subscribeCount == 2) + #expect(manager.state == .loading) + } + private enum TestError: Error { case shouldNotDerive }