diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift index 499eeb86..d3aed77a 100644 --- a/bitchat/App/LocationPresenceStore.swift +++ b/bitchat/App/LocationPresenceStore.swift @@ -7,8 +7,19 @@ final class LocationPresenceStore: ObservableObject { @Published private(set) var geoNicknames: [String: String] = [:] @Published private(set) var teleportedGeo: Set = [] + private let teleportedGeoCapacity: Int + private var teleportedGeoOrder: [String] = [] + + init(teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap) { + self.teleportedGeoCapacity = max(0, teleportedGeoCapacity) + } + func setCurrentGeohash(_ geohash: String?) { - currentGeohash = geohash?.lowercased() + let normalized = geohash?.lowercased() + if currentGeohash != normalized { + clearTeleportedGeo() + } + currentGeohash = normalized } func setNickname(_ nickname: String, for pubkeyHex: String) { @@ -28,24 +39,63 @@ final class LocationPresenceStore: ObservableObject { } func markTeleported(_ pubkeyHex: String) { - teleportedGeo.insert(pubkeyHex.lowercased()) + guard teleportedGeoCapacity > 0 else { + clearTeleportedGeo() + return + } + + let key = pubkeyHex.lowercased() + guard !teleportedGeo.contains(key) else { return } + + while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first { + teleportedGeoOrder.removeFirst() + teleportedGeo.remove(oldest) + } + + teleportedGeo.insert(key) + teleportedGeoOrder.append(key) } func clearTeleported(_ pubkeyHex: String) { - teleportedGeo.remove(pubkeyHex.lowercased()) + let key = pubkeyHex.lowercased() + teleportedGeo.remove(key) + teleportedGeoOrder.removeAll { $0 == key } } func replaceTeleportedGeo(_ pubkeys: Set) { - teleportedGeo = Set(pubkeys.map { $0.lowercased() }) + guard teleportedGeoCapacity > 0 else { + clearTeleportedGeo() + return + } + + var seen: Set = [] + var ordered: [String] = [] + for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + if ordered.count > teleportedGeoCapacity { + ordered = Array(ordered.suffix(teleportedGeoCapacity)) + } + teleportedGeoOrder = ordered + teleportedGeo = Set(ordered) + } + + func retainTeleportedGeo(keeping pubkeys: Set) { + let allowed = Set(pubkeys.map { $0.lowercased() }) + teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) } + teleportedGeo = teleportedGeo.intersection(allowed) } func clearTeleportedGeo() { teleportedGeo.removeAll() + teleportedGeoOrder.removeAll() } func reset() { currentGeohash = nil geoNicknames.removeAll() teleportedGeo.removeAll() + teleportedGeoOrder.removeAll() } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 6153c266..1c628da5 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -17,6 +17,7 @@ enum TransportConfig { static let privateChatCap: Int = 1337 static let meshTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337 + static let geoTeleportedParticipantsCap: Int = 1337 static let contentLRUCap: Int = 2000 // Timers diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index d282a6f5..830e043d 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -93,6 +93,10 @@ final class ChatNostrCoordinator { let hasTeleportTag = event.tags.contains { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" } + let content = event.content.trimmed + if hasTeleportTag && content.isEmpty { + return + } if hasTeleportTag { let key = event.pubkey.lowercased() @@ -111,7 +115,6 @@ final class ChatNostrCoordinator { } let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmed let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) let mentions = viewModel.parseMentions(from: content) @@ -272,6 +275,10 @@ final class ChatNostrCoordinator { let hasTeleportTag = event.tags.contains { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" } + let content = event.content + if hasTeleportTag && content.trimmed.isEmpty { + return + } let isSelf: Bool = { if let gh = viewModel.currentGeohash, @@ -314,14 +321,6 @@ final class ChatNostrCoordinator { } let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) - let content = event.content - - if let teleTag = event.tags.first(where: { $0.first == "t" }), - teleTag.count >= 2, - teleTag[1] == "teleport", - content.trimmed.isEmpty { - return - } let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let mentions = viewModel.parseMentions(from: content) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 2d7a8a03..e066f271 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -121,6 +121,17 @@ private extension ChatViewModelBootstrapper { viewModel?.objectWillChange.send() } .store(in: &viewModel.cancellables) + + viewModel.participantTracker.$visiblePeople + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] people in + Task { @MainActor [weak viewModel] in + viewModel?.locationPresenceStore.retainTeleportedGeo( + keeping: Set(people.map { $0.id }) + ) + } + } + .store(in: &viewModel.cancellables) } func loadPersistedViewState() { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index c58a6fd4..47aa5f9f 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -125,6 +125,26 @@ struct AppArchitectureTests { #expect(store.teleportedGeo.isEmpty) } + + @Test("LocationPresenceStore bounds and prunes teleported geohash participants") + @MainActor + func locationPresenceStoreBoundsTeleportedParticipants() { + let store = LocationPresenceStore(teleportedGeoCapacity: 2) + + store.setCurrentGeohash("u4pruy") + store.markTeleported("AAAAAA") + store.markTeleported("BBBBBB") + store.markTeleported("CCCCCC") + + #expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"])) + + store.retainTeleportedGeo(keeping: Set(["CCCCCC"])) + #expect(store.teleportedGeo == Set(["cccccc"])) + + store.setCurrentGeohash("u4pruz") + #expect(store.teleportedGeo.isEmpty) + } + @Test("PeerHandle equality and hashing use the canonical identity only") func peerHandleEqualityUsesCanonicalIdentity() { let first = PeerHandle(