From 88bafb41cc4e4244477c8052daf40527a921772b Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 18 Oct 2025 12:06:31 +0200 Subject: [PATCH 1/2] Remove LocationNotesCounter for privacy-first approach (#820) Simplifies geonotes architecture by eliminating all background subscriptions: - Delete LocationNotesCounter.swift (104 lines) and related tests (58 lines) - Remove background subscription system entirely - Icon is now constant darker orange (indicates availability, not state) - Subscription ONLY happens when user explicitly opens the sheet - Total: ~220 lines of code removed Privacy Benefits: - Zero network traffic until user action - No location leaking to relays via background polling - User must explicitly opt-in to discover notes The icon (note.text in orange) simply indicates the feature is available when location permission is granted. Notes are only fetched when the sheet is opened, prioritizing privacy over convenience. Co-authored-by: jack --- bitchat/Services/LocationNotesCounter.swift | 104 ------------------- bitchat/Views/ContentView.swift | 41 +------- bitchatTests/LocationNotesManagerTests.swift | 58 ----------- 3 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 bitchat/Services/LocationNotesCounter.swift 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/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 7ffd7dea..71af1454 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -25,7 +25,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 = "" @State private var textFieldSelection: NSRange? = nil @FocusState private var isTextFieldFocused: Bool @@ -51,7 +50,6 @@ struct ContentView: View { @State private var expandedMessageIDs: Set = [] @State private var showLocationNotes = false @State private var notesGeohash: String? = nil - @State private var sheetNotesCount: Int = 0 @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11 @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12 @@ -1287,10 +1285,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) @@ -1392,7 +1389,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) { @@ -1449,7 +1446,6 @@ struct ContentView: View { } } .onAppear { - updateNotesCounterSubscription() if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized, LocationChannelManager.shared.availableChannels.isEmpty { @@ -1457,16 +1453,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 { @@ -1482,31 +1475,3 @@ 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() - } - } -} 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) - } -} From 7040d9ecb0d6a8c588824d94034fa3b40f80a3b3 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 18 Oct 2025 12:44:06 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Add=20plus-minus=20feature=20to=20location?= =?UTF-8?q?=20notes=20(=C2=B1=201=20grid)=20(#821)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expands location notes coverage to include 8 neighboring geohash cells, creating a 3×3 grid around the user's current building-level location. Changes: - Add Geohash.neighbors() method to calculate 8 surrounding cells - Update LocationNotesManager to subscribe to center + neighbors (9 cells total) - Add NostrFilter.geohashNotes([String]) overload for multi-cell subscriptions - Display '± 1' indicator in LocationNotesView header Coverage: - Single cell: ~38m × 19m - 3×3 grid: ~114m × 57m total area - Better discovery of nearby activity Behavior: - Subscribe: Fetches notes from all 9 cells (single efficient subscription) - Post: Still uses center geohash only (correct privacy-preserving behavior) - UI: Shows 'geohash ± 1' to indicate expanded coverage Co-authored-by: jack --- bitchat/Nostr/NostrRelayManager.swift | 10 ++++ bitchat/Protocols/Geohash.swift | 53 +++++++++++++++++++++ bitchat/Services/LocationNotesManager.swift | 16 +++++-- bitchat/Views/LocationNotesView.swift | 2 +- 4 files changed, 76 insertions(+), 5 deletions(-) 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/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/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 ) }