perf(chat): batch public inserts, sort batch by ts; add per-sender + per-content token buckets; content-based near-dup suppression; reuse compiled regexes; single token scans per row; disable list animations during batches

This commit is contained in:
jack
2025-08-24 10:31:35 +02:00
parent 0260798712
commit 28f59bbd96
3 changed files with 206 additions and 86 deletions
+171 -58
View File
@@ -91,6 +91,91 @@ import UIKit
/// Acts as the primary coordinator between UI components and backend services,
/// implementing the BitchatDelegate protocol to handle network events.
class ChatViewModel: ObservableObject, BitchatDelegate {
// Precompiled regexes and detectors reused across formatting
private enum Regexes {
static let hashtag: NSRegularExpression = {
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
}()
static let mention: NSRegularExpression = {
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
}()
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let linkDetector: NSDataDetector? = {
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}()
static let quickCashuPresence: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
}
// 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 var rateBucketsBySender: [String: TokenBucket] = [:]
private var rateBucketsByContent: [String: TokenBucket] = [:]
private let senderBucketCapacity: Double = 5
private let senderBucketRefill: Double = 1 // tokens per second
private let contentBucketCapacity: Double = 3
private let contentBucketRefill: Double = 0.5 // tokens per second
private func normalizedSenderKey(for message: BitchatMessage) -> String {
if let spid = message.senderPeerID {
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
let bare: String = {
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
return spid
}()
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.lowercased()
}
}
return "name:" + message.sender.lowercased()
}
private func normalizedContentKey(_ content: String) -> String {
// Lowercase, trim, collapse whitespace, and bound length
let lowered = content.lowercased()
let trimmed = lowered.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
let prefix = String(collapsed.prefix(256))
// Fast djb2 hash from existing helper
let h = djb2(prefix)
return String(format: "h:%016llx", h)
}
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
@@ -286,6 +371,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Delivery tracking
private var cancellables = Set<AnyCancellable>()
// MARK: - Public message batching (UI perf)
// Buffer incoming public messages and flush in small batches to reduce UI invalidations
private var publicBuffer: [BitchatMessage] = []
private var publicBufferTimer: Timer? = nil
private let publicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -410,10 +501,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
guard let self = self else { return }
// Update peers directly
// Update peers directly; @Published drives UI updates
self.allPeers = peers
// Force UI update
self.objectWillChange.send()
// Update peer index for O(1) lookups
// Deduplicate peers by ID to prevent crash from duplicate keys
var uniquePeers: [String: BitchatPeer] = [:]
@@ -2873,11 +2962,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// For extremely long content, render as plain text to avoid heavy regex/layout work,
// unless the content includes Cashu tokens we want to chip-render below
let containsCashuEarly: Bool = {
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
if let rx = try? NSRegularExpression(pattern: pattern, options: []) {
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
}
return false
let rx = Regexes.quickCashuPresence
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: content.count)) > 0
}()
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
var plainStyle = AttributeContainer()
@@ -2887,33 +2973,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
: .system(size: 14, design: .monospaced)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagPattern = "#([a-zA-Z0-9_]+)"
// Allow optional '#abcd' suffix in mentions
let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)"
// Cashu token detector: cashuA/cashuB + long base64url; allow '.' and shorter variants
let cashuPattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
// Lightning invoices and links
let bolt11Pattern = "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b"
let lnurlPattern = "(?i)\\blnurl1[a-z0-9]{20,}\\b"
let lightningSchemePattern = "(?i)\\blightning:[^\\s]+"
// Reuse compiled regexes and detector
let hashtagRegex = Regexes.hashtag
let mentionRegex = Regexes.mention
let cashuRegex = Regexes.cashu
let bolt11Regex = Regexes.bolt11
let lnurlRegex = Regexes.lnurl
let lightningSchemeRegex = Regexes.lightningScheme
let detector = Regexes.linkDetector
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let cashuRegex = try? NSRegularExpression(pattern: cashuPattern, options: [])
let bolt11Regex = try? NSRegularExpression(pattern: bolt11Pattern, options: [])
let lnurlRegex = try? NSRegularExpression(pattern: lnurlPattern, options: [])
let lightningSchemeRegex = try? NSRegularExpression(pattern: lightningSchemePattern, options: [])
// Use NSDataDetector for URL detection
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let hashtagMatches = hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let mentionMatches = mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let cashuMatches = cashuRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let lightningMatches = lightningSchemeRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let bolt11Matches = bolt11Regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let lnurlMatches = lnurlRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
let cashuMatches = cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let lightningMatches = lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let bolt11Matches = bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
let lnurlMatches = lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
// Combine and sort matches, excluding hashtags/URLs overlapping mentions
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
@@ -3445,9 +3520,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private func addMessage(_ message: BitchatMessage) {
// Check for duplicates
guard !messages.contains(where: { $0.id == message.id }) else { return }
messages.append(message)
messages.sort { $0.timestamp < $1.timestamp }
trimMessagesIfNeeded()
}
@@ -5025,6 +5098,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
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 }
}
// Persist mesh messages to mesh timeline always
if !isGeo && finalMessage.sender != "system" {
meshTimeline.append(finalMessage)
@@ -5086,33 +5173,59 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
#endif
// Check if this is our own message being echoed back (avoid dup)
if finalMessage.sender != nickname && finalMessage.sender != "system" {
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
return
// Append via batching buffer (skip empty content)
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
enqueuePublic(finalMessage)
}
}
if finalMessage.sender != "system" {
// Check for duplicates
let messageExists = messages.contains { existingMsg in
if existingMsg.id == finalMessage.id { return true }
if existingMsg.content == finalMessage.content && existingMsg.sender == finalMessage.sender {
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
return timeDiff < 1.0
}
return false
}
if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
} else {
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
addMessage(finalMessage)
}
// MARK: - Public message batching helpers
private func enqueuePublic(_ message: BitchatMessage) {
publicBuffer.append(message)
schedulePublicFlush()
}
private func schedulePublicFlush() {
if publicBufferTimer != nil { return }
publicBufferTimer = Timer.scheduledTimer(withTimeInterval: publicFlushInterval, repeats: false) { [weak self] _ in
guard let self = self else { return }
self.flushPublicBuffer()
}
}
private func flushPublicBuffer() {
publicBufferTimer?.invalidate()
publicBufferTimer = nil
guard !publicBuffer.isEmpty else { return }
// Dedup against existing by id and near-duplicate messages by content (within ~1s), across senders
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 batchContentLatest: [String: Date] = [:]
for m in publicBuffer {
if seenIDs.contains(m.id) { continue }
let ckey = normalizedContentKey(m.content)
if let ts = recentContentLatest[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)
added.append(m)
batchContentLatest[ckey] = m.timestamp
}
publicBuffer.removeAll(keepingCapacity: true)
guard !added.isEmpty else { return }
// Rough chronological order: sort the batch by timestamp before appending
added.sort { $0.timestamp < $1.timestamp }
messages.append(contentsOf: added)
trimMessagesIfNeeded()
}
/// Check for mentions and send notifications
+7 -8
View File
@@ -310,9 +310,11 @@ struct ContentView: View {
} else {
// Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
@@ -328,9 +330,9 @@ struct ContentView: View {
}
// Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && message.content.extractCashuTokens().isEmpty {
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "Show less" : "Show more") {
Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
@@ -339,11 +341,7 @@ struct ContentView: View {
.padding(.top, 4)
}
// Link previews removed: URLs appear inline and are tappable
// Render payment chips (Lightning / Cashu) with rounded background
let lightningLinks = message.content.extractLightningLinks()
let cashuTokens = message.content.extractCashuTokens()
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
HStack(spacing: 8) {
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
@@ -453,6 +451,7 @@ struct ContentView: View {
.padding(.vertical, 2)
}
}
.transaction { $0.disablesAnimations = true }
.padding(.vertical, 4)
}
.background(backgroundColor)
+28 -20
View File
@@ -5,6 +5,21 @@
import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
@@ -23,8 +38,7 @@ extension String {
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuTokens(max: Int = 3) -> [String] {
let pattern = "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
let regex = RegexCache.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
@@ -44,28 +58,22 @@ extension String {
let ns = self as NSString
let full = NSRange(location: 0, length: ns.length)
// lightning: scheme
if let schemeRx = try? NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: []) {
for m in schemeRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
// BOLT11
if let boltRx = try? NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: []) {
for m in boltRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
// LNURL bech32
if let lnurlRx = try? NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: []) {
for m in lnurlRx.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
return results
}