perf(chat): batching, spam rate-limits, near-dup LRU, adaptive flush, faster trims, regex/detector reuse, conditional animations, late-insert, current-mode prewarm, Swift 6-safe timer/closures

This commit is contained in:
jack
2025-08-24 11:11:22 +02:00
parent e3866c9da6
commit e6bd472575
2 changed files with 104 additions and 41 deletions
+100 -41
View File
@@ -117,6 +117,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
static let quickCashuPresence: NSRegularExpression = { static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: []) try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}() }()
static let simplifyHTTPURL: NSRegularExpression = {
try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive])
}()
} }
// MARK: - Spam resilience: token buckets // MARK: - Spam resilience: token buckets
@@ -168,18 +171,54 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
private func normalizedContentKey(_ content: String) -> String { private func normalizedContentKey(_ content: String) -> String {
// Lowercase, trim, collapse whitespace, and bound length // Lowercase, simplify URLs (strip query/fragment), collapse whitespace, bound length
let lowered = content.lowercased() let lowered = content.lowercased()
let trimmed = lowered.trimmingCharacters(in: .whitespacesAndNewlines) let ns = lowered as NSString
let range = NSRange(location: 0, length: ns.length)
var simplified = ""
var last = 0
for m in Regexes.simplifyHTTPURL.matches(in: lowered, options: [], range: range) {
if m.range.location > last {
simplified += ns.substring(with: NSRange(location: last, length: m.range.location - last))
}
let url = ns.substring(with: m.range)
if let q = url.firstIndex(where: { $0 == "?" || $0 == "#" }) {
simplified += String(url[..<q])
} else {
simplified += url
}
last = m.range.location + m.range.length
}
if last < ns.length { simplified += ns.substring(with: NSRange(location: last, length: ns.length - last)) }
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
let prefix = String(collapsed.prefix(256)) let prefix = String(collapsed.prefix(256))
// Fast djb2 hash from existing helper // Fast djb2 hash
let h = djb2(prefix) let h = djb2(prefix)
return String(format: "h:%016llx", h) return String(format: "h:%016llx", h)
} }
// Persistent recent content map (LRU) to speed near-duplicate checks
private var contentLRUMap: [String: Date] = [:]
private var contentLRUOrder: [String] = []
private let contentLRUCap = 2000
private func recordContentKey(_ key: String, timestamp: Date) {
if contentLRUMap[key] == nil { contentLRUOrder.append(key) }
contentLRUMap[key] = timestamp
if contentLRUOrder.count > contentLRUCap {
let overflow = contentLRUOrder.count - contentLRUCap
for _ in 0..<overflow {
if let victim = contentLRUOrder.first {
contentLRUOrder.removeFirst()
contentLRUMap.removeValue(forKey: victim)
}
}
}
}
// MARK: - Published Properties // MARK: - Published Properties
@Published var messages: [BitchatMessage] = [] @Published var messages: [BitchatMessage] = []
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = 1337 // Maximum messages before oldest are removed private let maxMessages = 1337 // Maximum messages before oldest are removed
@Published var isConnected = false @Published var isConnected = false
private var hasNotifiedNetworkAvailable = false private var hasNotifiedNetworkAvailable = false
@@ -377,7 +416,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Buffer incoming public messages and flush in small batches to reduce UI invalidations // Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = [] private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil private var publicBufferTimer: Timer? = nil
private let publicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching private let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
private var dynamicPublicFlushInterval: TimeInterval = 0.08
private var recentBatchSizes: [Int] = []
@Published private(set) var isBatchingPublic: Bool = false @Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = 15.0 private let lateInsertThreshold: TimeInterval = 15.0
@@ -1187,6 +1228,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Add to main messages immediately for user feedback // Add to main messages immediately for user feedback
messages.append(message) messages.append(message)
// Update content LRU for near-dup detection
let ckey = normalizedContentKey(message.content)
recordContentKey(ckey, timestamp: message.timestamp)
// Persist to channel-specific timelines // Persist to channel-specific timelines
#if os(iOS) #if os(iOS)
switch activeChannel { switch activeChannel {
@@ -1196,10 +1240,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
case .location(let ch): case .location(let ch):
var arr = geoTimelines[ch.geohash] ?? [] var arr = geoTimelines[ch.geohash] ?? []
arr.append(message) arr.append(message)
if arr.count > geoTimelineCap { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
let remove = arr.count - geoTimelineCap
arr.removeFirst(remove)
}
geoTimelines[ch.geohash] = arr geoTimelines[ch.geohash] = arr
} }
#else #else
@@ -2988,13 +3029,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let lightningSchemeRegex = Regexes.lightningScheme let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector let detector = Regexes.linkDetector
let hashtagMatches = hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) let nsLen = content.count
let mentionMatches = mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) let hasMentionsHint = content.contains("@")
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let hasHashtagsHint = content.contains("#")
let cashuMatches = cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
let lightningMatches = lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
let bolt11Matches = bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) let hasCashuHint = content.lowercased().contains("cashu")
let lnurlMatches = lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : []
let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : []
// Combine and sort matches, excluding hashtags/URLs overlapping mentions // Combine and sort matches, excluding hashtags/URLs overlapping mentions
let mentionRanges = mentionMatches.map { $0.range(at: 0) } let mentionRanges = mentionMatches.map { $0.range(at: 0) }
@@ -3424,8 +3472,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func trimMessagesIfNeeded() { private func trimMessagesIfNeeded() {
if messages.count > maxMessages { if messages.count > maxMessages {
let removeCount = messages.count - maxMessages messages = Array(messages.suffix(maxMessages))
messages.removeFirst(removeCount)
} }
} }
@@ -3494,8 +3541,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func trimMeshTimelineIfNeeded() { private func trimMeshTimelineIfNeeded() {
if meshTimeline.count > meshTimelineCap { if meshTimeline.count > meshTimelineCap {
let removeCount = meshTimeline.count - meshTimelineCap meshTimeline = Array(meshTimeline.suffix(meshTimelineCap))
meshTimeline.removeFirst(removeCount)
} }
} }
@@ -5030,8 +5076,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats = chats privateChats = chats
trimPrivateChatMessagesIfNeeded(for: peerID) trimPrivateChatMessagesIfNeeded(for: peerID)
// Trigger UI update // UI updates via @Published reassignment above
objectWillChange.send()
// Handle fingerprint-based chat updates // Handle fingerprint-based chat updates
if let chatFingerprint = selectedPrivateChatFingerprint, if let chatFingerprint = selectedPrivateChatFingerprint,
@@ -5118,6 +5163,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if !(senderAllowed && contentAllowed) { return } if !(senderAllowed && contentAllowed) { return }
} }
// Size cap: drop extremely large public messages early
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
// Persist mesh messages to mesh timeline always // Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" { if !isGeo && finalMessage.sender != "system" {
meshTimeline.append(finalMessage) meshTimeline.append(finalMessage)
@@ -5130,10 +5178,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash { if let gh = currentGeohash {
var arr = geoTimelines[gh] ?? [] var arr = geoTimelines[gh] ?? []
arr.append(finalMessage) arr.append(finalMessage)
if arr.count > geoTimelineCap { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) }
let remove = arr.count - geoTimelineCap
arr.removeFirst(remove)
}
geoTimelines[gh] = arr geoTimelines[gh] = arr
} }
} }
@@ -5186,19 +5231,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// MARK: - Public message batching helpers // MARK: - Public message batching helpers
@MainActor
private func enqueuePublic(_ message: BitchatMessage) { private func enqueuePublic(_ message: BitchatMessage) {
publicBuffer.append(message) publicBuffer.append(message)
schedulePublicFlush() schedulePublicFlush()
} }
@MainActor
private func schedulePublicFlush() { private func schedulePublicFlush() {
if publicBufferTimer != nil { return } if publicBufferTimer != nil { return }
publicBufferTimer = Timer.scheduledTimer(withTimeInterval: publicFlushInterval, repeats: false) { [weak self] _ in publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval,
guard let self = self else { return } target: self,
self.flushPublicBuffer() selector: #selector(onPublicBufferTimerFired(_:)),
} userInfo: nil,
repeats: false)
} }
@MainActor
private func flushPublicBuffer() { private func flushPublicBuffer() {
publicBufferTimer?.invalidate() publicBufferTimer?.invalidate()
publicBufferTimer = nil publicBufferTimer = nil
@@ -5206,19 +5255,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders // Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders
var seenIDs = Set(messages.map { $0.id }) var seenIDs = Set(messages.map { $0.id })
let recent = Array(messages.suffix(200))
var recentContentLatest: [String: Date] = [:]
for e in recent {
let key = normalizedContentKey(e.content)
let prev = recentContentLatest[key]
if prev == nil || e.timestamp > prev! { recentContentLatest[key] = e.timestamp }
}
var added: [BitchatMessage] = [] var added: [BitchatMessage] = []
var batchContentLatest: [String: Date] = [:] var batchContentLatest: [String: Date] = [:]
for m in publicBuffer { for m in publicBuffer {
if seenIDs.contains(m.id) { continue } if seenIDs.contains(m.id) { continue }
let ckey = normalizedContentKey(m.content) let ckey = normalizedContentKey(m.content)
if let ts = recentContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue } if let ts = contentLRUMap[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue } if let ts = batchContentLatest[ckey], abs(ts.timeIntervalSince(m.timestamp)) < 1.0 { continue }
seenIDs.insert(m.id) seenIDs.insert(m.id)
added.append(m) added.append(m)
@@ -5240,16 +5282,33 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else { } else {
messages.append(m) messages.append(m)
} }
// Record content key for LRU
let ckey = normalizedContentKey(m.content)
recordContentKey(ckey, timestamp: m.timestamp)
} }
trimMessagesIfNeeded() trimMessagesIfNeeded()
// Prewarm formatting for new messages (light scheme) // Update batch size stats and adjust interval
recentBatchSizes.append(added.count)
if recentBatchSizes.count > 10 { recentBatchSizes.removeFirst(recentBatchSizes.count - 10) }
let avg = recentBatchSizes.isEmpty ? 0.0 : Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicPublicFlushInterval = avg > 100.0 ? 0.12 : basePublicFlushInterval
// Prewarm formatting cache for current UI color scheme only
for m in added { for m in added {
_ = self.formatMessageAsText(m, colorScheme: .light) _ = self.formatMessageAsText(m, colorScheme: currentColorScheme)
} }
// Reset batching flag on next runloop to allow brief UI transaction window // Reset batching flag (already on main actor)
DispatchQueue.main.async { [weak self] in self?.isBatchingPublic = false } isBatchingPublic = false
// If new items arrived during this flush, coalesce by flushing once more next tick
if !publicBuffer.isEmpty { schedulePublicFlush() }
} }
// Timer selector to avoid @Sendable closure capture issues under Swift 6
@MainActor @objc
private func onPublicBufferTimerFired(_ timer: Timer) {
flushPublicBuffer()
}
@MainActor
private func insertionIndexByTimestamp(_ ts: Date) -> Int { private func insertionIndexByTimestamp(_ ts: Date) -> Int {
var low = 0 var low = 0
var high = messages.count var high = messages.count
+4
View File
@@ -84,6 +84,10 @@ struct ContentView: View {
ZStack { ZStack {
// Base layer - Main public chat (always visible) // Base layer - Main public chat (always visible)
mainChatView mainChatView
.onAppear { viewModel.currentColorScheme = colorScheme }
.onChange(of: colorScheme) { newValue in
viewModel.currentColorScheme = newValue
}
// Private chat slide-over // Private chat slide-over
if viewModel.selectedPrivateChatPeer != nil { if viewModel.selectedPrivateChatPeer != nil {