mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:45:21 +00:00
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery Location notes (and geohash chat / DMs) intermittently stopped showing events because the Nostr relay layer lost subscriptions and blacklisted relays: - Replay active subscriptions on every relay (re)connect. Relays drop REQs with the socket; previously a drop silently killed the subscription on that relay for the rest of the session. Durable subscription intent now also survives disconnect()/resetAllConnections (background -> foreground). - Keep failed REQ sends queued instead of dropping them. - Raise the EOSE fallback from a fixed 2s Timer to a 10s injected schedule (Tor needs more than 2s), and settle EOSE trackers when a relay disconnects before answering so initial load doesn't stall. - Decay "permanently failed" relay markings after a 10-minute cooldown; previously ~9 minutes of outage (or one DNS hiccup) excluded a relay until app restart, with nothing resetting it on macOS. - Make geo relay selection deterministic (distance, then host) so publishers and subscribers with the same directory agree on relays. - Post .geoRelayDirectoryDidRefresh after a directory fetch and let LocationNotesManager auto-resubscribe out of the "no relays" state instead of requiring a manual retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Guard subscription activation on connection identity A REQ send completion from a dead socket could land after handleDisconnection cleared the relay's subscriptions and re-mark the subscription active, making the next connection skip the durable replay and leave that relay silent. Only mark a subscription active if the completing socket is still the relay's live connection. Regression test defers send completions in the mock so the stale completion deterministically interleaves between disconnect and reconnect. Addresses Codex review on #1333. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
274 lines
11 KiB
Swift
274 lines
11 KiB
Swift
import BitLogger
|
|
import Combine
|
|
import Foundation
|
|
|
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
|
struct LocationNotesDependencies {
|
|
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
|
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
|
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
|
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
|
|
|
|
var relayLookup: RelayLookup
|
|
var subscribe: Subscribe
|
|
var unsubscribe: Unsubscribe
|
|
var sendEvent: SendEvent
|
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
|
var now: () -> Date
|
|
// Fires when the geo relay directory refreshes; used to retry after "no relays".
|
|
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
|
|
private static let idBridge = NostrIdentityBridge()
|
|
|
|
static let live = LocationNotesDependencies(
|
|
relayLookup: { geohash, count in
|
|
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
|
},
|
|
subscribe: { filter, id, relays, handler, onEOSE in
|
|
NostrRelayManager.shared.subscribe(
|
|
filter: filter,
|
|
id: id,
|
|
relayUrls: relays,
|
|
handler: handler,
|
|
onEOSE: onEOSE
|
|
)
|
|
},
|
|
unsubscribe: { id in
|
|
NostrRelayManager.shared.unsubscribe(id: id)
|
|
},
|
|
sendEvent: { event, relays in
|
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
},
|
|
deriveIdentity: { geohash in
|
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
|
},
|
|
now: { Date() },
|
|
relayDirectoryUpdates: NotificationCenter.default
|
|
.publisher(for: .geoRelayDirectoryDidRefresh)
|
|
.map { _ in () }
|
|
.eraseToAnyPublisher()
|
|
)
|
|
}
|
|
|
|
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
|
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
|
@MainActor
|
|
final class LocationNotesManager: ObservableObject {
|
|
enum State: Equatable {
|
|
case idle
|
|
case loading
|
|
case ready
|
|
case noRelays
|
|
}
|
|
|
|
struct Note: Identifiable, Equatable {
|
|
let id: String
|
|
let pubkey: String
|
|
let content: String
|
|
let createdAt: Date
|
|
let nickname: String?
|
|
|
|
var displayName: String {
|
|
let suffix = String(pubkey.suffix(4))
|
|
if let nick = nickname?.trimmedOrNilIfEmpty {
|
|
return "\(nick)#\(suffix)"
|
|
}
|
|
return "anon#\(suffix)"
|
|
}
|
|
}
|
|
|
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
|
@Published private(set) var geohash: String
|
|
@Published private(set) var initialLoadComplete: Bool = false
|
|
@Published private(set) var state: State = .loading
|
|
@Published private(set) var errorMessage: String?
|
|
private var subscriptionID: String?
|
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
|
private var directoryUpdateCancellable: AnyCancellable?
|
|
private let dependencies: LocationNotesDependencies
|
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
|
|
|
private enum Strings {
|
|
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
|
|
|
static func failedToSend(_ detail: String) -> String {
|
|
String(
|
|
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
|
locale: .current,
|
|
detail
|
|
)
|
|
}
|
|
}
|
|
|
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
|
let norm = geohash.lowercased()
|
|
self.geohash = norm
|
|
self.dependencies = dependencies
|
|
// Validate geohash (building-level precision: 8 chars)
|
|
if !Geohash.isValidBuildingGeohash(norm) {
|
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
}
|
|
subscribe()
|
|
// The relay directory may load after init (remote fetch over Tor);
|
|
// retry automatically instead of staying stuck on "no relays".
|
|
directoryUpdateCancellable = dependencies.relayDirectoryUpdates
|
|
.sink { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, self.state == .noRelays else { return }
|
|
self.subscribe()
|
|
}
|
|
}
|
|
}
|
|
|
|
func setGeohash(_ newGeohash: String) {
|
|
let norm = newGeohash.lowercased()
|
|
guard norm != geohash else { return }
|
|
// Validate geohash (building-level precision: 8 chars)
|
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
|
return
|
|
}
|
|
if let sub = subscriptionID {
|
|
dependencies.unsubscribe(sub)
|
|
subscriptionID = nil
|
|
}
|
|
// Set loading state before clearing to prevent empty state flicker
|
|
state = .loading
|
|
initialLoadComplete = false
|
|
errorMessage = nil
|
|
geohash = norm
|
|
notes.removeAll()
|
|
noteIDs.removeAll()
|
|
subscribe()
|
|
}
|
|
|
|
func refresh() {
|
|
if let sub = subscriptionID {
|
|
dependencies.unsubscribe(sub)
|
|
subscriptionID = nil
|
|
}
|
|
// Set loading state before clearing to prevent empty state flicker
|
|
state = .loading
|
|
initialLoadComplete = false
|
|
errorMessage = nil
|
|
notes.removeAll()
|
|
noteIDs.removeAll()
|
|
subscribe()
|
|
}
|
|
|
|
func clearError() {
|
|
errorMessage = nil
|
|
}
|
|
|
|
private func subscribe() {
|
|
state = .loading
|
|
errorMessage = nil
|
|
if let sub = subscriptionID {
|
|
dependencies.unsubscribe(sub)
|
|
subscriptionID = nil
|
|
}
|
|
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
|
guard !relays.isEmpty else {
|
|
subscriptionID = nil
|
|
initialLoadComplete = true
|
|
state = .noRelays
|
|
errorMessage = Strings.noRelays
|
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
|
return
|
|
}
|
|
|
|
subscriptionID = subID
|
|
initialLoadComplete = false
|
|
|
|
// Subscribe to center + 8 neighbors (± 1 grid)
|
|
let neighbors = Geohash.neighbors(of: geohash)
|
|
let allGeohashes = [geohash] + neighbors
|
|
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
|
|
|
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
|
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
|
|
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
|
guard let self = self else { return }
|
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
|
// Ensure matching tag - accept any of our 9 geohashes
|
|
guard event.tags.contains(where: { tag in
|
|
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
|
}) else { return }
|
|
guard !self.noteIDs.contains(event.id) else { 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)
|
|
self.notes.append(note)
|
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
|
self.enforceMemoryCap()
|
|
self.state = .ready
|
|
}, { [weak self] in
|
|
guard let self = self else { return }
|
|
self.initialLoadComplete = true
|
|
if self.state != .noRelays {
|
|
self.state = .ready
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Send a location note for the current geohash using the per-geohash identity.
|
|
func send(content: String, nickname: String) {
|
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
|
guard !relays.isEmpty else {
|
|
state = .noRelays
|
|
errorMessage = Strings.noRelays
|
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
|
return
|
|
}
|
|
do {
|
|
let id = try dependencies.deriveIdentity(geohash)
|
|
let event = try NostrProtocol.createGeohashTextNote(
|
|
content: trimmed,
|
|
geohash: geohash,
|
|
senderIdentity: id,
|
|
nickname: nickname
|
|
)
|
|
dependencies.sendEvent(event, relays)
|
|
// Optimistic local-echo
|
|
let echo = Note(
|
|
id: event.id,
|
|
pubkey: id.publicKeyHex,
|
|
content: trimmed,
|
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
|
nickname: nickname
|
|
)
|
|
self.noteIDs.insert(event.id)
|
|
self.notes.insert(echo, at: 0)
|
|
self.enforceMemoryCap()
|
|
self.state = .ready
|
|
self.errorMessage = nil
|
|
} catch {
|
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
|
errorMessage = Strings.failedToSend(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
|
private func enforceMemoryCap() {
|
|
if notes.count > maxNotesInMemory {
|
|
let removed = notes.count - maxNotesInMemory
|
|
notes = Array(notes.prefix(maxNotesInMemory))
|
|
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
|
}
|
|
}
|
|
|
|
/// Explicitly cancel subscription and release resources.
|
|
func cancel() {
|
|
if let sub = subscriptionID {
|
|
dependencies.unsubscribe(sub)
|
|
subscriptionID = nil
|
|
}
|
|
state = .idle
|
|
errorMessage = nil
|
|
}
|
|
}
|