diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 48788cdd..722d8a77 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -906,6 +906,16 @@ struct NostrFilter: Encodable { filter.limit = limit return filter } + + // For location notes with neighbors: subscribe to multiple geohashes (center + neighbors) + static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [1] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["g": geohashes] + filter.limit = limit + return filter + } } // Dynamic coding key for tag filters diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index e5576775..f436ea27 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -119,4 +119,57 @@ enum Geohash { } return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) } + + /// Returns all 8 neighboring geohash cells at the same precision. + /// - Parameter geohash: Base32 geohash string. + /// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order). + static func neighbors(of geohash: String) -> [String] { + guard !geohash.isEmpty else { return [] } + + let precision = geohash.count + let bounds = decodeBounds(geohash) + let center = decodeCenter(geohash) + + // Calculate cell dimensions + let latHeight = bounds.latMax - bounds.latMin + let lonWidth = bounds.lonMax - bounds.lonMin + + // Helper to wrap longitude around ±180 + func wrapLongitude(_ lon: Double) -> Double { + var wrapped = lon + while wrapped > 180.0 { wrapped -= 360.0 } + while wrapped < -180.0 { wrapped += 360.0 } + return wrapped + } + + // Helper to clamp latitude to ±90 + func clampLatitude(_ lat: Double) -> Double { + return max(-90.0, min(90.0, lat)) + } + + // Calculate 8 neighbor centers + let neighbors: [(lat: Double, lon: Double)] = [ + (center.lat + latHeight, center.lon), // N + (center.lat + latHeight, center.lon + lonWidth), // NE + (center.lat, center.lon + lonWidth), // E + (center.lat - latHeight, center.lon + lonWidth), // SE + (center.lat - latHeight, center.lon), // S + (center.lat - latHeight, center.lon - lonWidth), // SW + (center.lat, center.lon - lonWidth), // W + (center.lat + latHeight, center.lon - lonWidth) // NW + ] + + // Encode each neighbor, handling boundary conditions + return neighbors.compactMap { neighbor in + let lat = clampLatitude(neighbor.lat) + let lon = wrapLongitude(neighbor.lon) + + // Skip if we've crossed a pole (latitude clamped to boundary) + if (neighbor.lat > 90.0 || neighbor.lat < -90.0) { + return nil + } + + return encode(latitude: lat, longitude: lon, precision: precision) + } + } } diff --git a/bitchat/Services/LocationNotesCounter.swift b/bitchat/Services/LocationNotesCounter.swift deleted file mode 100644 index b3c32118..00000000 --- a/bitchat/Services/LocationNotesCounter.swift +++ /dev/null @@ -1,104 +0,0 @@ -import BitLogger -import Foundation - -struct LocationNotesCounterDependencies { - 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 - - var relayLookup: RelayLookup - var subscribe: Subscribe - var unsubscribe: Unsubscribe - - static let live = LocationNotesCounterDependencies( - 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) - } - ) -} - -/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). -@MainActor -final class LocationNotesCounter: ObservableObject { - static let shared = LocationNotesCounter() - - @Published private(set) var geohash: String? = nil - @Published private(set) var count: Int? = 0 - @Published private(set) var initialLoadComplete: Bool = false - @Published private(set) var relayAvailable: Bool = true - - private var subscriptionID: String? = nil - private var noteIDs = Set() - private let dependencies: LocationNotesCounterDependencies - - private init(dependencies: LocationNotesCounterDependencies = .live) { - self.dependencies = dependencies - } - - init(testDependencies: LocationNotesCounterDependencies) { - self.dependencies = testDependencies - } - - func subscribe(geohash gh: String) { - let norm = gh.lowercased() - if geohash == norm, subscriptionID != nil { return } - // Validate geohash (building-level precision: 8 chars) - guard Geohash.isValidBuildingGeohash(norm) else { - SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session) - return - } - // Unsubscribe previous without clearing count to avoid flicker - if let sub = subscriptionID { dependencies.unsubscribe(sub) } - subscriptionID = nil - geohash = norm - noteIDs.removeAll() - initialLoadComplete = false - relayAvailable = true - - // Subscribe only to the building geohash (precision 8) - let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))" - let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount) - guard !relays.isEmpty else { - relayAvailable = false - initialLoadComplete = true - count = 0 - SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session) - return - } - - subscriptionID = subID - let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200) - dependencies.subscribe(filter, subID, relays, { [weak self] event in - guard let self = self else { return } - guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } - guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return } - if !self.noteIDs.contains(event.id) { - self.noteIDs.insert(event.id) - self.count = self.noteIDs.count - } - }, { [weak self] in - self?.initialLoadComplete = true - }) - } - - func cancel() { - if let sub = subscriptionID { dependencies.unsubscribe(sub) } - subscriptionID = nil - geohash = nil - count = 0 - noteIDs.removeAll() - relayAvailable = true - } -} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index 5c828a01..e9153163 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject { subscriptionID = subID initialLoadComplete = false - // For persistent notes, allow relays to return recent history without an aggressive time cutoff - let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200) + + // 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 - guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) 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 diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index d3fece2e..22c5adc1 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -38,7 +38,6 @@ struct ContentView: View { @EnvironmentObject var viewModel: ChatViewModel @ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared - @ObservedObject private var notesCounter = LocationNotesCounter.shared @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme @@ -1349,10 +1348,9 @@ struct ContentView: View { showLocationNotes = true }) { HStack(alignment: .center, spacing: 4) { - let hasNotes = (notesCounter.count ?? 0) > 0 - Image(systemName: "long.text.page.and.pencil") + Image(systemName: "note.text") .font(.bitchatSystem(size: 12)) - .foregroundColor(hasNotes ? textColor : Color.gray) + .foregroundColor(Color.orange.opacity(0.8)) .padding(.top, 1) } .fixedSize(horizontal: true, vertical: false) @@ -1454,7 +1452,7 @@ struct ContentView: View { }) { Group { if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { - LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt }) + LocationNotesView(geohash: gh) .environmentObject(viewModel) } else { VStack(spacing: 12) { @@ -1511,7 +1509,6 @@ struct ContentView: View { } } .onAppear { - updateNotesCounterSubscription() if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { @@ -1519,16 +1516,13 @@ struct ContentView: View { } } .onChange(of: locationManager.selectedChannel) { _ in - updateNotesCounterSubscription() if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { LocationChannelManager.shared.refreshChannels() } } - .onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() } .onChange(of: locationManager.permissionState) { _ in - updateNotesCounterSubscription() if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { @@ -1545,34 +1539,6 @@ struct ContentView: View { } -// MARK: - Notes Counter Subscription Helper -extension ContentView { - private func updateNotesCounterSubscription() { - switch locationManager.selectedChannel { - case .mesh: - // Ensure we have a fresh one-shot location fix so building geohash is current - if locationManager.permissionState == .authorized { - LocationChannelManager.shared.refreshChannels() - } - if locationManager.permissionState == .authorized { - if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { - LocationNotesCounter.shared.subscribe(geohash: building) - } else { - // Keep existing subscription if we had one to avoid flicker - // Only cancel if we have no known geohash - if LocationNotesCounter.shared.geohash == nil { - LocationNotesCounter.shared.cancel() - } - } - } else { - LocationNotesCounter.shared.cancel() - } - case .location: - LocationNotesCounter.shared.cancel() - } - } -} - // MARK: - Helper Views // Rounded payment chip button diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 8dbe901d..a705719e 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -141,7 +141,7 @@ struct LocationNotesView: View { String( format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"), locale: .current, - geohash, count + "\(geohash) ± 1", count ) } diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift index fcf3254e..ce6c3e80 100644 --- a/bitchatTests/LocationNotesManagerTests.swift +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -91,61 +91,3 @@ struct LocationNotesManagerTests { case shouldNotDerive } } - -@MainActor -struct LocationNotesCounterTests { - @Test func subscribeWithoutRelaysMarksUnavailable() { - var subscribeCalled = false - let deps = LocationNotesCounterDependencies( - relayLookup: { _, _ in [] }, - subscribe: { _, _, _, _, _ in subscribeCalled = true }, - unsubscribe: { _ in } - ) - - let counter = LocationNotesCounter(testDependencies: deps) - counter.subscribe(geohash: "u4pruydq") - - #expect(!subscribeCalled) - #expect(!counter.relayAvailable) - #expect(counter.initialLoadComplete) - #expect(counter.count == 0) - } - - @Test func subscribeCountsUniqueNotes() { - var storedHandler: ((NostrEvent) -> Void)? - var storedEOSE: (() -> Void)? - let deps = LocationNotesCounterDependencies( - relayLookup: { _, _ in ["wss://relay.geo"] }, - subscribe: { filter, id, relays, handler, eose in - #expect(relays == ["wss://relay.geo"]) - #expect(filter.kinds == [1]) - #expect(!id.isEmpty) - storedHandler = handler - storedEOSE = eose - }, - unsubscribe: { _ in } - ) - - let counter = LocationNotesCounter(testDependencies: deps) - counter.subscribe(geohash: "u4pruydq") - - var first = NostrEvent( - pubkey: "pub", - createdAt: Date(), - kind: .textNote, - tags: [["g", "u4pruydq"]], - content: "a" - ) - first.id = "eventA" - storedHandler?(first) - - let duplicate = first - storedHandler?(duplicate) - - storedEOSE?() - - #expect(counter.relayAvailable) - #expect(counter.count == 1) - #expect(counter.initialLoadComplete) - } -}