From a91978c10e015bcd115ed289e172e3b6376fdbaf Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:13:57 +0100 Subject: [PATCH] Extract public timeline helpers and rate limiter (#869) --- bitchat/ViewModels/ChatViewModel.swift | 574 +++--------------- .../ViewModels/GeoChannelCoordinator.swift | 110 ++++ bitchat/ViewModels/MessageRateLimiter.swift | 77 +++ .../ViewModels/MinimalDistancePalette.swift | 210 +++++++ bitchat/ViewModels/PublicTimelineStore.swift | 124 ++++ 5 files changed, 620 insertions(+), 475 deletions(-) create mode 100644 bitchat/ViewModels/GeoChannelCoordinator.swift create mode 100644 bitchat/ViewModels/MessageRateLimiter.swift create mode 100644 bitchat/ViewModels/MinimalDistancePalette.swift create mode 100644 bitchat/ViewModels/PublicTimelineStore.swift diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1a3c5d0b..fce48174 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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) @MainActor @@ -158,12 +137,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } - private var rateBucketsBySender: [String: TokenBucket] = [:] - private var rateBucketsByContent: [String: TokenBucket] = [:] - private let senderBucketCapacity: Double = TransportConfig.uiSenderRateBucketCapacity - private let senderBucketRefill: Double = TransportConfig.uiSenderRateBucketRefillPerSec // tokens per second - private let contentBucketCapacity: Double = TransportConfig.uiContentRateBucketCapacity - private let contentBucketRefill: Double = TransportConfig.uiContentRateBucketRefillPerSec // tokens per second + private var publicRateLimiter = MessageRateLimiter( + senderCapacity: TransportConfig.uiSenderRateBucketCapacity, + senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec, + contentCapacity: TransportConfig.uiContentRateBucketCapacity, + contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec + ) @MainActor private func normalizedSenderKey(for message: BitchatMessage) -> String { @@ -396,13 +375,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private var torStatusAnnounced = false private var torProgressCancellable: AnyCancellable? 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 // "tor restarted" after an actual restart, not the first launch. private var torRestartPending: Bool = false // Ensure we set up DM subscription only once per app session private var nostrHandlersSetup: Bool = false + private var geoChannelCoordinator: GeoChannelCoordinator? // MARK: - Caches @@ -433,13 +411,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @Published var isAppInfoPresented: Bool = false @Published var showScreenshotPrivacyWarning: Bool = false - // Messages are naturally ephemeral - no persistent storage - // Persist mesh public timeline across channel switches - private var meshTimeline: [BitchatMessage] = [] - private let meshTimelineCap = TransportConfig.meshTimelineCap - // Persist per-geohash public timelines across switches - private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages - private let geoTimelineCap = TransportConfig.geoTimelineCap + private var timelineStore = PublicTimelineStore( + meshCap: TransportConfig.meshTimelineCap, + geohashCap: TransportConfig.geoTimelineCap + ) // Channel activity tracking for background nudges private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityNotifyAt: [String: Date] = [:] @@ -658,14 +633,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } self.resubscribeCurrentGeohash() // Re-init sampling for regional + bookmarked geohashes after reconnect - let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash } - let bookmarks = GeohashBookmarksStore.shared.bookmarks - let union = Array(Set(regional).union(bookmarks)) - if TorManager.shared.isForeground() { - self.beginGeohashSampling(for: union) - } else { - self.endGeohashSampling() - } + self.geoChannelCoordinator?.refreshSampling() } } } @@ -682,72 +650,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } .store(in: &cancellables) - // Observe location channel selection - LocationChannelManager.shared.$selectedChannel - .receive(on: DispatchQueue.main) - .sink { [weak self] channel in - guard let self = self else { return } - Task { @MainActor in - self.switchLocationChannel(to: channel) - } + geoChannelCoordinator = GeoChannelCoordinator( + onChannelSwitch: { [weak self] channel in + self?.switchLocationChannel(to: channel) + }, + beginSampling: { [weak self] geohashes in + self?.beginGeohashSampling(for: geohashes) + }, + 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 LocationChannelManager.shared.$teleported @@ -1504,27 +1417,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { mentions: mentions.isEmpty ? nil : mentions ) - // Add to main messages immediately for user feedback - messages.append(message) + timelineStore.append(message, to: activeChannel) + refreshVisibleMessages(from: activeChannel) // Update content LRU for near-dup detection let ckey = normalizedContentKey(message.content) 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() // UI updates automatically via @Published var messages @@ -1611,7 +1510,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { processedNostrEventOrder.removeAll() switch channel { case .mesh: - messages = meshTimeline + refreshVisibleMessages(from: .mesh) // Debug: log if any empty messages are present let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count if emptyMesh > 0 { @@ -1620,18 +1519,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { stopGeoParticipantsTimer() geohashPeople = [] teleportedGeo.removeAll() - case .location(let ch): - // Persist the cleaned/sorted timeline for this geohash - let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? [] - geoTimelines[ch.geohash] = deduped - messages = deduped + case .location: + refreshVisibleMessages(from: channel) } // If switching to a location channel, flush any pending geohash-only system messages - if case .location = channel, !pendingGeohashSystemMessages.isEmpty { - for m in pendingGeohashSystemMessages { - addPublicSystemMessage(m) + if case .location = channel { + for content in timelineStore.drainPendingGeohashSystemMessages() { + addPublicSystemMessage(content) } - pendingGeohashSystemMessages.removeAll(keepingCapacity: false) } // Unsubscribe previous if let sub = geoSubscriptionID { @@ -2040,26 +1935,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Remove their public messages from current geohash timeline and visible list if let gh = currentGeohash { - if var arr = geoTimelines[gh] { - arr.removeAll { msg in - if let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat { - if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } - } - return false - } - geoTimelines[gh] = arr + let predicate: (BitchatMessage) -> Bool = { [self] msg in + guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false } + if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex } + return false } - // Also filter currently bound messages if we are in geohash channel - switch activeChannel { - case .location: - 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 + timelineStore.removeMessages(in: gh, where: predicate) + if case .location = activeChannel { + messages.removeAll(where: predicate) } } @@ -2071,7 +1954,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } // 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( String( @@ -2187,7 +2072,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { Task { @MainActor in lastGeoNotificationAt[gh] = now // 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 nick = geoNicknames[event.pubkey.lowercased()] let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix @@ -2205,12 +2089,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { senderPeerID: PeerID(nostr: event.pubkey), mentions: mentions.isEmpty ? nil : mentions ) - if !arr.contains(where: { $0.id == msg.id }) { - arr.append(msg) - if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } - geoTimelines[gh] = arr + if timelineStore.appendIfAbsent(msg, toGeohash: gh) { + NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) } - NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) } } @@ -2613,19 +2494,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { senderPeerID: senderPeerID, deliveryStatus: .sending ) - messages.append(message) - 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 - } + timelineStore.append(message, to: activeChannel) + messages = timelineStore.messages(for: activeChannel) trimMessagesIfNeeded() } @@ -2762,19 +2632,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { removedMessage = messages.remove(at: idx) } - meshTimeline.removeAll { $0.id == messageID } - - 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 - } - } + if let storeRemoved = timelineStore.removeMessage(withID: messageID) { + removedMessage = removedMessage ?? storeRemoved } 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 private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { if let spid = message.senderPeerID { @@ -4388,16 +4253,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return getPeerPaletteColor(for: peerID, isDark: isDark) } - private func trimMeshTimelineIfNeeded() { - if meshTimeline.count > meshTimelineCap { - meshTimeline = Array(meshTimeline.suffix(meshTimelineCap)) - } - } - - // 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 + // MARK: - Peer Palette Coordination + private let meshPalette = MinimalDistancePalette(config: .mesh) + private let nostrPalette = MinimalDistancePalette(config: .nostr) @MainActor private func meshSeed(for peerID: PeerID) -> String { @@ -4409,272 +4267,66 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { - // Ensure palette up to date for current peer set and seeds - rebuildPeerPaletteIfNeeded() + if peerID == meshService.myPeerID { + return .orange + } - let entry = (isDark ? peerPaletteDark[peerID.id] : peerPaletteLight[peerID.id]) - let orange = Color.orange - if peerID == meshService.myPeerID { 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) + meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) + if let color = meshPalette.color(for: peerID.id, isDark: isDark) { + return color } - // Fallback to seed color if not in palette (e.g., transient) return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) } @MainActor - private func rebuildPeerPaletteIfNeeded() { - // Build current peer->seed map (excluding self) + private func currentMeshPaletteSeeds() -> [String: String] { let myID = meshService.myPeerID - var currentSeeds: [String: String] = [:] - for p in allPeers where p.peerID != myID { - currentSeeds[p.peerID.id] = meshSeed(for: p.peerID) + var seeds: [String: String] = [:] + for peer in allPeers where peer.peerID != myID { + seeds[peer.peerID.id] = meshSeed(for: peer.peerID) } - // If seeds unchanged and palette exists for both themes, skip - 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.. 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() - 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.. 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 + return seeds } - // 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 private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { - rebuildNostrPaletteIfNeeded() - let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased]) - let myHex: String? = { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - return id.publicKeyHex.lowercased() - } - return nil - }() - 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) + let myHex = currentGeohashIdentityHex() + if let myHex, pubkeyHexLowercased == myHex { + return .orange + } + + nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) + if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { + return color } - // Fallback to seed color if not in palette (e.g., transient) return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) } @MainActor - private func rebuildNostrPaletteIfNeeded() { - // Build seeds map from currently visible geohash people (excluding self) - let myHex: String? = { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - 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 + private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { + var seeds: [String: String] = [:] + let excluded = myHex ?? "" + for person in visibleGeohashPeople() where person.id != excluded { + seeds[person.id] = "nostr:" + person.id } - nostrPaletteSeeds = currentSeeds + return seeds + } - let slotCount = max(8, TransportConfig.uiPeerPaletteSlots) - let avoidCenter = 30.0 / 360.0 - let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta - var slots: [Double] = [] - for i in 0.. String? { + if case .location(let channel) = LocationChannelManager.shared.selectedChannel, + let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) { + return identity.publicKeyHex.lowercased() } - if slots.isEmpty { - for i in 0.. 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() - 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.. 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 + return nil } // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { // Clear messages from current timeline - switch activeChannel { - case .mesh: - messages.removeAll() - meshTimeline.removeAll() - case .location(let ch): - messages.removeAll() - geoTimelines[ch.geohash] = [] - } + messages.removeAll() + timelineStore.clear(channel: activeChannel) // Delete associated media files (images, voice notes, files) in background // Only delete from current chat to avoid removing private chat media @@ -5520,13 +5172,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { timestamp: Date(), isRelay: false ) - // Persist to mesh timeline - meshTimeline.append(systemMessage) - trimMeshTimelineIfNeeded() - // Only show inline if mesh is the active channel - if case .mesh = activeChannel { - messages.append(systemMessage) - } + timelineStore.append(systemMessage, to: .mesh) + refreshVisibleMessages() + trimMessagesIfNeeded() objectWillChange.send() } @@ -5540,20 +5188,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { timestamp: Date(), isRelay: false ) - // Append to current visible messages - messages.append(systemMessage) + timelineStore.append(systemMessage, to: activeChannel) + refreshVisibleMessages(from: activeChannel) // Track the content key so relayed copies of the same system-style message are ignored let contentKey = normalizedContentKey(systemMessage.content) recordContentKey(contentKey, timestamp: systemMessage.timestamp) - // Persist into the backing store for the active channel to survive rebinds - switch activeChannel { - case .mesh: - meshTimeline.append(systemMessage) - case .location(let ch): - var arr = geoTimelines[ch.geohash] ?? [] - arr.append(systemMessage) - geoTimelines[ch.geohash] = arr - } + trimMessagesIfNeeded() objectWillChange.send() } @@ -5564,7 +5204,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { addPublicSystemMessage(content) } else { // 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. @@ -6369,14 +6009,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) let contentKey = normalizedContentKey(finalMessage.content) - let now = Date() - 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 } + if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return } } // Size cap: drop extremely large public messages early @@ -6384,22 +6017,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Persist mesh messages to mesh timeline always if !isGeo && finalMessage.sender != "system" { - if !meshTimeline.contains(where: { $0.id == finalMessage.id }) { - meshTimeline.append(finalMessage) - trimMeshTimelineIfNeeded() - } + timelineStore.append(finalMessage, to: .mesh) } // Persist geochat messages to per-geohash timeline if isGeo && finalMessage.sender != "system" { if let gh = currentGeohash { - var arr = geoTimelines[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 - } + _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh) } } diff --git a/bitchat/ViewModels/GeoChannelCoordinator.swift b/bitchat/ViewModels/GeoChannelCoordinator.swift new file mode 100644 index 00000000..8df6331a --- /dev/null +++ b/bitchat/ViewModels/GeoChannelCoordinator.swift @@ -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() + 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() + } +} diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift new file mode 100644 index 00000000..5bca9a6d --- /dev/null +++ b/bitchat/ViewModels/MessageRateLimiter.swift @@ -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() + } +} diff --git a/bitchat/ViewModels/MinimalDistancePalette.swift b/bitchat/ViewModels/MinimalDistancePalette.swift new file mode 100644 index 00000000..fb05d3c9 --- /dev/null +++ b/bitchat/ViewModels/MinimalDistancePalette.swift @@ -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.. 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() + 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.. 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 + ) +} diff --git a/bitchat/ViewModels/PublicTimelineStore.swift b/bitchat/ViewModels/PublicTimelineStore.swift new file mode 100644 index 00000000..92a76cc6 --- /dev/null +++ b/bitchat/ViewModels/PublicTimelineStore.swift @@ -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)) + } +}