Extract public timeline helpers and rate limiter (#869)

This commit is contained in:
jack
2025-10-27 14:13:57 +01:00
committed by GitHub
parent 14c4e586fc
commit a91978c10e
5 changed files with 620 additions and 475 deletions
+99 -475
View File
@@ -124,27 +124,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}() }()
} }
// MARK: - Spam resilience: token buckets
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool) private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
@MainActor @MainActor
@@ -158,12 +137,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
private var rateBucketsBySender: [String: TokenBucket] = [:] private var publicRateLimiter = MessageRateLimiter(
private var rateBucketsByContent: [String: TokenBucket] = [:] senderCapacity: TransportConfig.uiSenderRateBucketCapacity,
private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec,
private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second contentCapacity: TransportConfig.uiContentRateBucketCapacity,
private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec
private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second )
@MainActor @MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String { private func normalizedSenderKey(for message: BitchatMessage) -> String {
@@ -396,13 +375,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var torStatusAnnounced = false private var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable? private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1 private var lastTorProgressAnnounced = -1
// Queue geohash-only system messages if user isn't on a location channel yet
private var pendingGeohashSystemMessages: [String] = []
// Track whether a Tor restart is pending so we only announce // Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch. // "tor restarted" after an actual restart, not the first launch.
private var torRestartPending: Bool = false private var torRestartPending: Bool = false
// Ensure we set up DM subscription only once per app session // Ensure we set up DM subscription only once per app session
private var nostrHandlersSetup: Bool = false private var nostrHandlersSetup: Bool = false
private var geoChannelCoordinator: GeoChannelCoordinator?
// MARK: - Caches // MARK: - Caches
@@ -433,13 +411,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@Published var isAppInfoPresented: Bool = false @Published var isAppInfoPresented: Bool = false
@Published var showScreenshotPrivacyWarning: Bool = false @Published var showScreenshotPrivacyWarning: Bool = false
// Messages are naturally ephemeral - no persistent storage private var timelineStore = PublicTimelineStore(
// Persist mesh public timeline across channel switches meshCap: TransportConfig.meshTimelineCap,
private var meshTimeline: [BitchatMessage] = [] geohashCap: TransportConfig.geoTimelineCap
private let meshTimelineCap = TransportConfig.meshTimelineCap )
// Persist per-geohash public timelines across switches
private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages
private let geoTimelineCap = TransportConfig.geoTimelineCap
// Channel activity tracking for background nudges // Channel activity tracking for background nudges
private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:] private var lastPublicActivityNotifyAt: [String: Date] = [:]
@@ -658,14 +633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
self.resubscribeCurrentGeohash() self.resubscribeCurrentGeohash()
// Re-init sampling for regional + bookmarked geohashes after reconnect // Re-init sampling for regional + bookmarked geohashes after reconnect
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash } self.geoChannelCoordinator?.refreshSampling()
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
} }
} }
} }
@@ -682,72 +650,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
.store(in: &cancellables) .store(in: &cancellables)
// Observe location channel selection geoChannelCoordinator = GeoChannelCoordinator(
LocationChannelManager.shared.$selectedChannel onChannelSwitch: { [weak self] channel in
.receive(on: DispatchQueue.main) self?.switchLocationChannel(to: channel)
.sink { [weak self] channel in },
guard let self = self else { return } beginSampling: { [weak self] geohashes in
Task { @MainActor in self?.beginGeohashSampling(for: geohashes)
self.switchLocationChannel(to: channel) },
} endSampling: { [weak self] in
self?.endGeohashSampling()
} }
.store(in: &cancellables) )
// Initialize with current selection
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Foreground-only: sample nearby geohashes + bookmarks (disabled in background)
LocationChannelManager.shared.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self = self else { return }
let regional = channels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
// Also observe bookmark changes to update sampling
GeohashBookmarksStore.shared.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self = self else { return }
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let union = Array(Set(regional).union(bookmarks))
Task { @MainActor in
if TorManager.shared.isForeground() {
self.beginGeohashSampling(for: union)
} else {
self.endGeohashSampling()
}
}
}
.store(in: &cancellables)
// Kick off initial sampling if we have regional channels or bookmarks (foreground only)
do {
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
let bookmarks = GeohashBookmarksStore.shared.bookmarks
let union = Array(Set(regional).union(bookmarks))
if !union.isEmpty && TorManager.shared.isForeground() {
Task { @MainActor in self.beginGeohashSampling(for: union) }
}
}
// Refresh channels once when authorized to seed sampling
LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { state in
if state == .authorized { LocationChannelManager.shared.refreshChannels() }
}
.store(in: &cancellables)
// Track teleport flag changes to keep our own teleported marker in sync with regional status // Track teleport flag changes to keep our own teleported marker in sync with regional status
LocationChannelManager.shared.$teleported LocationChannelManager.shared.$teleported
@@ -1504,27 +1417,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
// Add to main messages immediately for user feedback timelineStore.append(message, to: activeChannel)
messages.append(message) refreshVisibleMessages(from: activeChannel)
// Update content LRU for near-dup detection // Update content LRU for near-dup detection
let ckey = normalizedContentKey(message.content) let ckey = normalizedContentKey(message.content)
recordContentKey(ckey, timestamp: message.timestamp) recordContentKey(ckey, timestamp: message.timestamp)
// Persist to channel-specific timelines
switch activeChannel {
case .mesh:
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(message)
if arr.count > geoTimelineCap {
arr = Array(arr.suffix(geoTimelineCap))
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded() trimMessagesIfNeeded()
// UI updates automatically via @Published var messages // UI updates automatically via @Published var messages
@@ -1611,7 +1510,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
processedNostrEventOrder.removeAll() processedNostrEventOrder.removeAll()
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present // Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 { if emptyMesh > 0 {
@@ -1620,18 +1519,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
stopGeoParticipantsTimer() stopGeoParticipantsTimer()
geohashPeople = [] geohashPeople = []
teleportedGeo.removeAll() teleportedGeo.removeAll()
case .location(let ch): case .location:
// Persist the cleaned/sorted timeline for this geohash refreshVisibleMessages(from: channel)
let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? []
geoTimelines[ch.geohash] = deduped
messages = deduped
} }
// If switching to a location channel, flush any pending geohash-only system messages // If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel, !pendingGeohashSystemMessages.isEmpty { if case .location = channel {
for m in pendingGeohashSystemMessages { for content in timelineStore.drainPendingGeohashSystemMessages() {
addPublicSystemMessage(m) addPublicSystemMessage(content)
} }
pendingGeohashSystemMessages.removeAll(keepingCapacity: false)
} }
// Unsubscribe previous // Unsubscribe previous
if let sub = geoSubscriptionID { if let sub = geoSubscriptionID {
@@ -2040,26 +1935,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Remove their public messages from current geohash timeline and visible list // Remove their public messages from current geohash timeline and visible list
if let gh = currentGeohash { if let gh = currentGeohash {
if var arr = geoTimelines[gh] { let predicate: (BitchatMessage) -> Bool = { [self] msg in
arr.removeAll { msg in guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false }
if let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat { if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex }
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } return false
}
return false
}
geoTimelines[gh] = arr
} }
// Also filter currently bound messages if we are in geohash channel timelineStore.removeMessages(in: gh, where: predicate)
switch activeChannel { if case .location = activeChannel {
case .location: messages.removeAll(where: predicate)
messages.removeAll { msg in
if let spid = msg.senderPeerID , spid.isGeoDM || spid.isGeoChat {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
return false
}
case .mesh:
break
} }
} }
@@ -2071,7 +1954,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Remove mapping keys pointing to this pubkey to avoid accidental resolution // Remove mapping keys pointing to this pubkey to avoid accidental resolution
for (k, v) in nostrKeyMapping where v.lowercased() == hex { nostrKeyMapping.removeValue(forKey: k) } for (key, value) in self.nostrKeyMapping where value.lowercased() == hex {
self.nostrKeyMapping.removeValue(forKey: key)
}
addSystemMessage( addSystemMessage(
String( String(
@@ -2187,7 +2072,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
Task { @MainActor in Task { @MainActor in
lastGeoNotificationAt[gh] = now lastGeoNotificationAt[gh] = now
// Pre-populate the target geohash timeline so the triggering message appears when user opens it // Pre-populate the target geohash timeline so the triggering message appears when user opens it
var arr = geoTimelines[gh] ?? []
let senderSuffix = String(event.pubkey.suffix(4)) let senderSuffix = String(event.pubkey.suffix(4))
let nick = geoNicknames[event.pubkey.lowercased()] let nick = geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
@@ -2205,12 +2089,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
senderPeerID: PeerID(nostr: event.pubkey), senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
if !arr.contains(where: { $0.id == msg.id }) { if timelineStore.appendIfAbsent(msg, toGeohash: gh) {
arr.append(msg) NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
} }
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
} }
} }
@@ -2613,19 +2494,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
senderPeerID: senderPeerID, senderPeerID: senderPeerID,
deliveryStatus: .sending deliveryStatus: .sending
) )
messages.append(message) timelineStore.append(message, to: activeChannel)
switch activeChannel { messages = timelineStore.messages(for: activeChannel)
case .mesh:
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(message)
if arr.count > geoTimelineCap {
arr = Array(arr.suffix(geoTimelineCap))
}
geoTimelines[ch.geohash] = arr
}
trimMessagesIfNeeded() trimMessagesIfNeeded()
} }
@@ -2762,19 +2632,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
removedMessage = messages.remove(at: idx) removedMessage = messages.remove(at: idx)
} }
meshTimeline.removeAll { $0.id == messageID } if let storeRemoved = timelineStore.removeMessage(withID: messageID) {
removedMessage = removedMessage ?? storeRemoved
for key in Array(geoTimelines.keys) {
var entries = geoTimelines[key] ?? []
if let idx = entries.firstIndex(where: { $0.id == messageID }) {
removedMessage = removedMessage ?? entries[idx]
entries.remove(at: idx)
if entries.isEmpty {
geoTimelines.removeValue(forKey: key)
} else {
geoTimelines[key] = entries
}
}
} }
var chats = privateChats var chats = privateChats
@@ -4360,6 +4219,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
@MainActor
private func refreshVisibleMessages(from channel: ChannelID? = nil) {
let target = channel ?? activeChannel
messages = timelineStore.messages(for: target)
}
@MainActor @MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID { if let spid = message.senderPeerID {
@@ -4388,16 +4253,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return getPeerPaletteColor(for: peerID, isDark: isDark) return getPeerPaletteColor(for: peerID, isDark: isDark)
} }
private func trimMeshTimelineIfNeeded() { // MARK: - Peer Palette Coordination
if meshTimeline.count > meshTimelineCap { private let meshPalette = MinimalDistancePalette(config: .mesh)
meshTimeline = Array(meshTimeline.suffix(meshTimelineCap)) private let nostrPalette = MinimalDistancePalette(config: .nostr)
}
}
// MARK: - Peer List Minimal-Distance Palette
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
@MainActor @MainActor
private func meshSeed(for peerID: PeerID) -> String { private func meshSeed(for peerID: PeerID) -> String {
@@ -4409,272 +4267,66 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
// Ensure palette up to date for current peer set and seeds if peerID == meshService.myPeerID {
rebuildPeerPaletteIfNeeded() return .orange
}
let entry = (isDark ? peerPaletteDark[peerID.id] : peerPaletteLight[peerID.id]) meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
let orange = Color.orange if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
if peerID == meshService.myPeerID { return orange } return color
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
} }
// Fallback to seed color if not in palette (e.g., transient)
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
} }
@MainActor @MainActor
private func rebuildPeerPaletteIfNeeded() { private func currentMeshPaletteSeeds() -> [String: String] {
// Build current peer->seed map (excluding self)
let myID = meshService.myPeerID let myID = meshService.myPeerID
var currentSeeds: [String: String] = [:] var seeds: [String: String] = [:]
for p in allPeers where p.peerID != myID { for peer in allPeers where peer.peerID != myID {
currentSeeds[p.peerID.id] = meshSeed(for: p.peerID) seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
} }
// If seeds unchanged and palette exists for both themes, skip return seeds
if currentSeeds == peerPaletteSeeds,
peerPaletteLight.keys.count == currentSeeds.count,
peerPaletteDark.keys.count == currentSeeds.count {
return
}
peerPaletteSeeds = currentSeeds
// Generate evenly spaced hue slots avoiding self-orange range
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
let avoidCenter = 30.0 / 360.0
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
var slots: [Double] = []
for i in 0..<slotCount {
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
}
if slots.isEmpty {
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
// Helper to compute circular distance
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
// Assign slots to peers to maximize minimal distance, deterministically
let peers = currentSeeds.keys.sorted() // stable order
// Preferred slot index by seed (wrapping to available slots)
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = (currentSeeds[id] ?? id).djb2()
// Map to available slot range deterministically
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] {
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
// Keep previous assignments if still valid to minimize churn
let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight
for (id, entry) in prev {
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
// First ring assignment using free slots
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
// If a preferred slot free, take it
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
// Choose free slot maximizing minimal distance to used hues
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
// Bias toward preferred index for stability
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
// Overflow peers: assign additional rings by reusing slots with stable preference
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
// Spread over slots by rotating from preferred with a golden-step
let goldenStep = 7 // small prime step for dispersion
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
return mapping
}
let mapping = assign(for: currentSeeds)
peerPaletteLight = mapping
peerPaletteDark = mapping
} }
// MARK: - Nostr People Minimal-Distance Palette (same algo)
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
@MainActor @MainActor
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
rebuildNostrPaletteIfNeeded() let myHex = currentGeohashIdentityHex()
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased]) if let myHex, pubkeyHexLowercased == myHex {
let myHex: String? = { return .orange
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, }
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased() nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
} if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
return nil return color
}()
if let me = myHex, pubkeyHexLowercased == me { return .orange }
let saturation: Double = isDark ? 0.80 : 0.70
let baseBrightness: Double = isDark ? 0.75 : 0.45
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
if let e = entry {
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
} }
// Fallback to seed color if not in palette (e.g., transient)
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
} }
@MainActor @MainActor
private func rebuildNostrPaletteIfNeeded() { private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
// Build seeds map from currently visible geohash people (excluding self) var seeds: [String: String] = [:]
let myHex: String? = { let excluded = myHex ?? ""
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, for person in visibleGeohashPeople() where person.id != excluded {
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { seeds[person.id] = "nostr:" + person.id
return id.publicKeyHex.lowercased()
}
return nil
}()
let people = visibleGeohashPeople()
var currentSeeds: [String: String] = [:]
for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id }
if currentSeeds == nostrPaletteSeeds,
nostrPaletteLight.keys.count == currentSeeds.count,
nostrPaletteDark.keys.count == currentSeeds.count {
return
} }
nostrPaletteSeeds = currentSeeds return seeds
}
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots) @MainActor
let avoidCenter = 30.0 / 360.0 private func currentGeohashIdentityHex() -> String? {
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta if case .location(let channel) = LocationChannelManager.shared.selectedChannel,
var slots: [Double] = [] let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) {
for i in 0..<slotCount { return identity.publicKeyHex.lowercased()
let hue = Double(i) / Double(slotCount)
if abs(hue - avoidCenter) < avoidDelta { continue }
slots.append(hue)
} }
if slots.isEmpty { return nil
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
}
func circDist(_ a: Double, _ b: Double) -> Double {
let d = abs(a - b)
return d > 0.5 ? 1.0 - d : d
}
let peers = currentSeeds.keys.sorted()
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
let h = (currentSeeds[id] ?? id).djb2()
let idx = Int(h % UInt64(slots.count))
return (id, idx)
})
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight
for (id, entry) in prev {
if peers.contains(id), entry.slot < slots.count {
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
usedSlots.insert(entry.slot)
usedHues.append(slots[entry.slot])
}
}
let unassigned = peers.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = prefIndex[id] ?? 0
if !usedSlots.contains(preferred) && preferred < slots.count {
mapping[id] = (preferred, 0, slots[preferred])
usedSlots.insert(preferred)
usedHues.append(slots[preferred])
continue
}
var bestSlot: Int? = nil
var bestScore: Double = -1
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
let hue = slots[sIdx]
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDist + 0.05 * bias
if score > bestScore { bestScore = score; bestSlot = sIdx }
}
if let s = bestSlot {
mapping[id] = (s, 0, slots[s])
usedSlots.insert(s)
usedHues.append(slots[s])
}
}
let stillUnassigned = peers.filter { mapping[$0] == nil }
if !stillUnassigned.isEmpty {
for (idx, id) in stillUnassigned.enumerated() {
let preferred = prefIndex[id] ?? 0
let goldenStep = 7
let s = (preferred + idx * goldenStep) % slots.count
mapping[id] = (s, 1, slots[s])
}
}
nostrPaletteLight = mapping
nostrPaletteDark = mapping
} }
// Clear the current public channel's timeline (visible + persistent buffer) // Clear the current public channel's timeline (visible + persistent buffer)
@MainActor @MainActor
func clearCurrentPublicTimeline() { func clearCurrentPublicTimeline() {
// Clear messages from current timeline // Clear messages from current timeline
switch activeChannel { messages.removeAll()
case .mesh: timelineStore.clear(channel: activeChannel)
messages.removeAll()
meshTimeline.removeAll()
case .location(let ch):
messages.removeAll()
geoTimelines[ch.geohash] = []
}
// Delete associated media files (images, voice notes, files) in background // Delete associated media files (images, voice notes, files) in background
// Only delete from current chat to avoid removing private chat media // Only delete from current chat to avoid removing private chat media
@@ -5520,13 +5172,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
timestamp: Date(), timestamp: Date(),
isRelay: false isRelay: false
) )
// Persist to mesh timeline timelineStore.append(systemMessage, to: .mesh)
meshTimeline.append(systemMessage) refreshVisibleMessages()
trimMeshTimelineIfNeeded() trimMessagesIfNeeded()
// Only show inline if mesh is the active channel
if case .mesh = activeChannel {
messages.append(systemMessage)
}
objectWillChange.send() objectWillChange.send()
} }
@@ -5540,20 +5188,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
timestamp: Date(), timestamp: Date(),
isRelay: false isRelay: false
) )
// Append to current visible messages timelineStore.append(systemMessage, to: activeChannel)
messages.append(systemMessage) refreshVisibleMessages(from: activeChannel)
// Track the content key so relayed copies of the same system-style message are ignored // Track the content key so relayed copies of the same system-style message are ignored
let contentKey = normalizedContentKey(systemMessage.content) let contentKey = normalizedContentKey(systemMessage.content)
recordContentKey(contentKey, timestamp: systemMessage.timestamp) recordContentKey(contentKey, timestamp: systemMessage.timestamp)
// Persist into the backing store for the active channel to survive rebinds trimMessagesIfNeeded()
switch activeChannel {
case .mesh:
meshTimeline.append(systemMessage)
case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? []
arr.append(systemMessage)
geoTimelines[ch.geohash] = arr
}
objectWillChange.send() objectWillChange.send()
} }
@@ -5564,7 +5204,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
addPublicSystemMessage(content) addPublicSystemMessage(content)
} else { } else {
// Not on a location channel yet: queue to show when user switches // Not on a location channel yet: queue to show when user switches
pendingGeohashSystemMessages.append(content) timelineStore.queueGeohashSystemMessage(content)
} }
} }
// Send a public message without adding a local user echo. // Send a public message without adding a local user echo.
@@ -6369,14 +6009,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if shouldRateLimit { if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage) let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = normalizedContentKey(finalMessage.content) let contentKey = normalizedContentKey(finalMessage.content)
let now = Date() if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return }
var sBucket = rateBucketsBySender[senderKey] ?? TokenBucket(capacity: senderBucketCapacity, tokens: senderBucketCapacity, refillPerSec: senderBucketRefill, lastRefill: now)
let senderAllowed = sBucket.allow(now: now)
rateBucketsBySender[senderKey] = sBucket
var cBucket = rateBucketsByContent[contentKey] ?? TokenBucket(capacity: contentBucketCapacity, tokens: contentBucketCapacity, refillPerSec: contentBucketRefill, lastRefill: now)
let contentAllowed = cBucket.allow(now: now)
rateBucketsByContent[contentKey] = cBucket
if !(senderAllowed && contentAllowed) { return }
} }
// Size cap: drop extremely large public messages early // Size cap: drop extremely large public messages early
@@ -6384,22 +6017,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Persist mesh messages to mesh timeline always // Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" { if !isGeo && finalMessage.sender != "system" {
if !meshTimeline.contains(where: { $0.id == finalMessage.id }) { timelineStore.append(finalMessage, to: .mesh)
meshTimeline.append(finalMessage)
trimMeshTimelineIfNeeded()
}
} }
// Persist geochat messages to per-geohash timeline // Persist geochat messages to per-geohash timeline
if isGeo && finalMessage.sender != "system" { if isGeo && finalMessage.sender != "system" {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh)
// Dedup by message ID before appending to per-geohash timeline
if !arr.contains(where: { $0.id == finalMessage.id }) {
arr.append(finalMessage)
if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
geoTimelines[gh] = arr
}
} }
} }
@@ -0,0 +1,110 @@
//
// GeoChannelCoordinator.swift
// bitchat
//
// Centralizes Combine wiring for location channel selection and sampling.
//
import Combine
import Foundation
import Tor
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
private var bookmarkedGeohashes: [String] = []
init(
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
) {
self.locationManager = locationManager ?? LocationChannelManager.shared
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? TorManager.shared
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
start()
}
func start() {
regionalGeohashes = locationManager.availableChannels.map { $0.geohash }
bookmarkedGeohashes = bookmarksStore.bookmarks
locationManager.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
}
}
.store(in: &cancellables)
locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] channels in
guard let self else { return }
self.regionalGeohashes = channels.map { $0.geohash }
self.updateSampling()
}
.store(in: &cancellables)
bookmarksStore.$bookmarks
.receive(on: DispatchQueue.main)
.sink { [weak self] bookmarks in
guard let self else { return }
self.bookmarkedGeohashes = bookmarks
self.updateSampling()
}
.store(in: &cancellables)
locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self, state == .authorized else { return }
Task { @MainActor [weak self] in
self?.locationManager.refreshChannels()
}
}
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
}
updateSampling()
}
private func updateSampling() {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
} else {
endSampling()
}
}
}
func refreshSampling() {
updateSampling()
}
}
@@ -0,0 +1,77 @@
//
// MessageRateLimiter.swift
// bitchat
//
// Handles per-sender and per-content token buckets for public message intake.
//
import Foundation
struct MessageRateLimiter {
private struct TokenBucket {
var capacity: Double
var tokens: Double
var refillPerSec: Double
var lastRefill: Date
mutating func allow(cost: Double = 1.0, now: Date = Date()) -> Bool {
let dt = now.timeIntervalSince(lastRefill)
if dt > 0 {
tokens = min(capacity, tokens + dt * refillPerSec)
lastRefill = now
}
if tokens >= cost {
tokens -= cost
return true
}
return false
}
}
private var senderBuckets: [String: TokenBucket] = [:]
private var contentBuckets: [String: TokenBucket] = [:]
private let senderCapacity: Double
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
}
@@ -0,0 +1,210 @@
//
// MinimalDistancePalette.swift
// bitchat
//
// Lightweight palette generator that keeps peer colors evenly spaced.
//
import Foundation
import SwiftUI
final class MinimalDistancePalette {
struct Config {
let slotCount: Int
let avoidCenterHue: Double
let avoidHueDelta: Double
let saturationLight: Double
let saturationDark: Double
let baseBrightnessLight: Double
let baseBrightnessDark: Double
let ringBrightnessDeltaLight: Double
let ringBrightnessDeltaDark: Double
let preferredBiasWeight: Double
let goldenStep: Int
init(
slotCount: Int,
avoidCenterHue: Double,
avoidHueDelta: Double,
saturationLight: Double,
saturationDark: Double,
baseBrightnessLight: Double,
baseBrightnessDark: Double,
ringBrightnessDeltaLight: Double,
ringBrightnessDeltaDark: Double,
preferredBiasWeight: Double = 0.05,
goldenStep: Int = 7
) {
self.slotCount = slotCount
self.avoidCenterHue = avoidCenterHue
self.avoidHueDelta = avoidHueDelta
self.saturationLight = saturationLight
self.saturationDark = saturationDark
self.baseBrightnessLight = baseBrightnessLight
self.baseBrightnessDark = baseBrightnessDark
self.ringBrightnessDeltaLight = ringBrightnessDeltaLight
self.ringBrightnessDeltaDark = ringBrightnessDeltaDark
self.preferredBiasWeight = preferredBiasWeight
self.goldenStep = goldenStep
}
}
private struct Entry {
let slot: Int
let ring: Int
let hue: Double
}
private let config: Config
private var currentSeeds: [String: String] = [:]
private var entries: [String: Entry] = [:]
private var previousEntries: [String: Entry] = [:]
init(config: Config) {
self.config = config
}
@MainActor
func ensurePalette(for seeds: [String: String]) {
guard seeds != currentSeeds || entries.count != seeds.count else { return }
previousEntries = entries
currentSeeds = seeds
rebuildEntries()
}
@MainActor
func color(for identifier: String, isDark: Bool) -> Color? {
guard let entry = entries[identifier] else { return nil }
let saturation = isDark ? config.saturationDark : config.saturationLight
let baseBrightness = isDark ? config.baseBrightnessDark : config.baseBrightnessLight
let ringDelta = isDark ? config.ringBrightnessDeltaDark : config.ringBrightnessDeltaLight
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(entry.ring)))
return Color(hue: entry.hue, saturation: saturation, brightness: brightness)
}
@MainActor
func reset() {
currentSeeds.removeAll()
entries.removeAll()
previousEntries.removeAll()
}
@MainActor
private func rebuildEntries() {
guard !currentSeeds.isEmpty else {
entries.removeAll()
return
}
let slotCount = max(8, config.slotCount)
var slots: [Double] = []
for idx in 0..<slotCount {
let hue = Double(idx) / Double(slotCount)
if abs(hue - config.avoidCenterHue) < config.avoidHueDelta {
continue
}
slots.append(hue)
}
if slots.isEmpty {
for idx in 0..<slotCount {
slots.append(Double(idx) / Double(slotCount))
}
}
func circularDistance(_ a: Double, _ b: Double) -> Double {
let diff = abs(a - b)
return diff > 0.5 ? 1.0 - diff : diff
}
let peerIDs = currentSeeds.keys.sorted()
let preferredIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peerIDs.map { id in
let seed = currentSeeds[id] ?? id
let hash = seed.djb2()
let index = Int(hash % UInt64(slots.count))
return (id, index)
})
var mapping: [String: Entry] = [:]
var usedSlots = Set<Int>()
var usedHues: [Double] = []
let prior = entries.isEmpty ? previousEntries : entries
for (id, entry) in prior {
guard currentSeeds.keys.contains(id), entry.slot < slots.count else { continue }
let hue = slots[entry.slot]
mapping[id] = Entry(slot: entry.slot, ring: entry.ring, hue: hue)
usedSlots.insert(entry.slot)
usedHues.append(hue)
}
let unassigned = peerIDs.filter { mapping[$0] == nil }
for id in unassigned {
let preferred = preferredIndex[id] ?? 0
if !usedSlots.contains(preferred), preferred < slots.count {
let hue = slots[preferred]
mapping[id] = Entry(slot: preferred, ring: 0, hue: hue)
usedSlots.insert(preferred)
usedHues.append(hue)
continue
}
var bestSlot: Int?
var bestScore = -Double.infinity
for slot in 0..<slots.count where !usedSlots.contains(slot) {
let hue = slots[slot]
let minDistance = usedHues.isEmpty ? 1.0 : usedHues.map { circularDistance(hue, $0) }.min() ?? 1.0
let bias = 1.0 - (Double((abs(slot - (preferredIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
let score = minDistance + config.preferredBiasWeight * bias
if score > bestScore {
bestScore = score
bestSlot = slot
}
}
if let slot = bestSlot {
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 0, hue: hue)
usedSlots.insert(slot)
usedHues.append(hue)
}
}
let remaining = peerIDs.filter { mapping[$0] == nil }
if !remaining.isEmpty {
for (index, id) in remaining.enumerated() {
let preferred = preferredIndex[id] ?? 0
let slot = (preferred + index * config.goldenStep) % slots.count
let hue = slots[slot]
mapping[id] = Entry(slot: slot, ring: 1, hue: hue)
}
}
entries = mapping
}
}
extension MinimalDistancePalette.Config {
static let mesh = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
static let nostr = MinimalDistancePalette.Config(
slotCount: TransportConfig.uiPeerPaletteSlots,
avoidCenterHue: 30.0 / 360.0,
avoidHueDelta: TransportConfig.uiColorHueAvoidanceDelta,
saturationLight: 0.70,
saturationDark: 0.80,
baseBrightnessLight: 0.45,
baseBrightnessDark: 0.75,
ringBrightnessDeltaLight: TransportConfig.uiPeerPaletteRingBrightnessDeltaLight,
ringBrightnessDeltaDark: TransportConfig.uiPeerPaletteRingBrightnessDeltaDark
)
}
@@ -0,0 +1,124 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
}
}