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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-10-18 12:06:31 +02:00
committed by GitHub
co-authored by jack
parent fb43a8b0f5
commit 88bafb41cc
3 changed files with 3 additions and 200 deletions
-104
View File
@@ -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<String>()
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
}
}
+3 -38
View File
@@ -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<String> = []
@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()
}
}
}
@@ -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)
}
}