mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:45:20 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user