mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
Location notes: fix performance and UI issues (#774)
* Location notes: fix performance and UI issues Performance fixes: - Add Set-based duplicate detection (O(1) vs O(n) lookup) - Eliminates lag when receiving 200+ notes during EOSE Correctness fixes: - Fix optimistic echo timestamp to match signed event timestamp - Add echo IDs to noteIDs set for consistency UI improvements: - Remove duplicate "loading recent notes" text in header - Simplify toolbar icon color logic for immediate green indication - Icon now reliably turns green when notes exist in geohash Tests: All 3 LocationNotes tests passing * Location notes: add remaining robustness fixes Additional improvements: - Align counter/manager limits to 200 (prevents showing count higher than displayable) - Set loading state before clearing notes to eliminate UI flicker on geohash change - Add geohash validation for building-level precision (8 valid base32 chars) - Add defensive 500-note memory cap with automatic trimming - Clear stale notesGeohash state on sheet dismiss Tests: - Fix test geohashes to use valid base32 characters - All 3 LocationNotes tests passing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,14 @@ enum Geohash {
|
||||
return map
|
||||
}()
|
||||
|
||||
/// Validates a geohash string for building-level precision (8 characters).
|
||||
/// - Parameter geohash: The geohash string to validate
|
||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||
guard geohash.count == 8 else { return false }
|
||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||
}
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
|
||||
@@ -54,6 +54,11 @@ final class LocationNotesCounter: ObservableObject {
|
||||
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
|
||||
@@ -74,7 +79,7 @@ final class LocationNotesCounter: ObservableObject {
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
||||
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 }
|
||||
|
||||
@@ -74,7 +74,9 @@ final class LocationNotesManager: ObservableObject {
|
||||
@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 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")
|
||||
@@ -89,20 +91,35 @@ final class LocationNotesManager: ObservableObject {
|
||||
}
|
||||
|
||||
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||
self.geohash = geohash.lowercased()
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -111,7 +128,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -147,12 +169,14 @@ final class LocationNotesManager: ObservableObject {
|
||||
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 }
|
||||
if self.notes.contains(where: { $0.id == event.id }) { 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 }
|
||||
@@ -188,10 +212,12 @@ final class LocationNotesManager: ObservableObject {
|
||||
id: event.id,
|
||||
pubkey: id.publicKeyHex,
|
||||
content: trimmed,
|
||||
createdAt: dependencies.now(),
|
||||
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 {
|
||||
@@ -200,6 +226,15 @@ final class LocationNotesManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -1188,8 +1188,7 @@ struct ContentView: View {
|
||||
showLocationNotes = true
|
||||
}) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
let currentCount = (notesCounter.count ?? 0)
|
||||
let hasNotes = (!notesCounter.initialLoadComplete ? max(currentCount, sheetNotesCount) : currentCount) > 0
|
||||
let hasNotes = (notesCounter.count ?? 0) > 0
|
||||
Image(systemName: "long.text.page.and.pencil")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(hasNotes ? textColor : Color.gray)
|
||||
@@ -1290,7 +1289,9 @@ struct ContentView: View {
|
||||
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
|
||||
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
|
||||
}
|
||||
.sheet(isPresented: $showLocationNotes) {
|
||||
.sheet(isPresented: $showLocationNotes, onDismiss: {
|
||||
notesGeohash = nil
|
||||
}) {
|
||||
Group {
|
||||
if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash {
|
||||
LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt })
|
||||
|
||||
@@ -125,11 +125,7 @@ struct LocationNotesView: View {
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .loading && !manager.initialLoadComplete {
|
||||
Text(Strings.loadingRecent)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
} else if manager.state == .noRelays {
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -16,7 +16,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
||||
// now: { Date() }
|
||||
// )
|
||||
//
|
||||
// let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
||||
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
//
|
||||
// XCTAssertFalse(subscribeCalled)
|
||||
// XCTAssertEqual(manager.state, .noRelays)
|
||||
@@ -66,7 +66,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
||||
now: { Date() }
|
||||
)
|
||||
|
||||
let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps)
|
||||
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||
XCTAssertEqual(manager.state, .loading)
|
||||
|
||||
@@ -74,7 +74,7 @@ final class LocationNotesManagerTests: XCTestCase {
|
||||
pubkey: "pub",
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "abcd1234"]],
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "hi"
|
||||
)
|
||||
event.id = "event1"
|
||||
@@ -102,7 +102,7 @@ final class LocationNotesCounterTests: XCTestCase {
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "abcdefgh")
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
XCTAssertFalse(subscribeCalled)
|
||||
XCTAssertFalse(counter.relayAvailable)
|
||||
@@ -126,13 +126,13 @@ final class LocationNotesCounterTests: XCTestCase {
|
||||
)
|
||||
|
||||
let counter = LocationNotesCounter(testDependencies: deps)
|
||||
counter.subscribe(geohash: "abcdefgh")
|
||||
counter.subscribe(geohash: "u4pruydq")
|
||||
|
||||
var first = NostrEvent(
|
||||
pubkey: "pub",
|
||||
createdAt: Date(),
|
||||
kind: .textNote,
|
||||
tags: [["g", "abcdefgh"]],
|
||||
tags: [["g", "u4pruydq"]],
|
||||
content: "a"
|
||||
)
|
||||
first.id = "eventA"
|
||||
|
||||
Reference in New Issue
Block a user