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) - } -}