Extract public message pipeline from ChatViewModel (#870)

* Extract public timeline helpers and rate limiter

* Extract public message pipeline

* Fix Swift concurrency warnings

* Incorporate public chat review feedback

* Tighten nostr key removal loop

* Address review feedback

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-10-28 12:10:09 +01:00
committed by GitHub
co-authored by jack
parent a91978c10e
commit 2edbe2bbbd
3 changed files with 244 additions and 117 deletions
+44 -115
View File
@@ -365,7 +365,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private let keychain: KeychainManagerProtocol
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
@Published private var activeChannel: ChannelID = .mesh
@Published private(set) var activeChannel: ChannelID = .mesh
private var geoSubscriptionID: String? = nil
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
@@ -454,14 +454,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint
// 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 basePublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var dynamicPublicFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval
private var recentBatchSizes: [Int] = []
private let publicMessagePipeline: PublicMessagePipeline
@Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -507,6 +501,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
self.idBridge = idBridge
self.identityManager = identityManager
self.meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
self.publicMessagePipeline = PublicMessagePipeline()
// Load persisted read receipts
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
@@ -559,6 +554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Start mesh service immediately
meshService.startServices()
publicMessagePipeline.delegate = self
publicMessagePipeline.updateActiveChannel(activeChannel)
// Check initial Bluetooth state after a brief delay to allow centralManager initialization
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self = self else { return }
@@ -1501,10 +1499,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func switchLocationChannel(to channel: ChannelID) {
// Flush pending public buffer to avoid cross-channel bleed
publicBufferTimer?.invalidate(); publicBufferTimer = nil
publicBuffer.removeAll(keepingCapacity: false)
// Reset pending public batches to avoid cross-channel bleed
publicMessagePipeline.reset()
activeChannel = channel
publicMessagePipeline.updateActiveChannel(channel)
// Reset deduplication set and optionally hydrate timeline for mesh
processedNostrEvents.removeAll()
processedNostrEventOrder.removeAll()
@@ -6043,113 +6041,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !messages.contains(where: { $0.id == finalMessage.id }) {
enqueuePublic(finalMessage)
publicMessagePipeline.enqueue(finalMessage)
}
}
}
// MARK: - Public message batching helpers
@MainActor
private func enqueuePublic(_ message: BitchatMessage) {
publicBuffer.append(message)
schedulePublicFlush()
}
@MainActor
private func schedulePublicFlush() {
if publicBufferTimer != nil { return }
publicBufferTimer = Timer.scheduledTimer(timeInterval: dynamicPublicFlushInterval,
target: self,
selector: #selector(onPublicBufferTimerFired(_:)),
userInfo: nil,
repeats: false)
}
@MainActor
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 })
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 = contentLRUMap[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 }
// Indicate batching for conditional UI animations
isBatchingPublic = true
// Rough chronological order: sort the batch by timestamp before inserting
added.sort { $0.timestamp < $1.timestamp }
// Channel-aware insertion policy: geohash uses strict ordering; mesh allows small out-of-order appends
let threshold: TimeInterval = {
switch activeChannel {
case .location: return TransportConfig.uiLateInsertThresholdGeo
case .mesh: return TransportConfig.uiLateInsertThreshold
}
}()
let lastTs = messages.last?.timestamp ?? .distantPast
for m in added {
if m.timestamp < lastTs.addingTimeInterval(-threshold) {
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else if threshold == 0 {
// Strict ordering for geohash: always insert by timestamp
let idx = insertionIndexByTimestamp(m.timestamp)
if idx >= messages.count { messages.append(m) } else { messages.insert(m, at: idx) }
} else {
messages.append(m)
}
// Record content key for LRU
let ckey = normalizedContentKey(m.content)
recordContentKey(ckey, timestamp: m.timestamp)
}
trimMessagesIfNeeded()
// 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 {
_ = self.formatMessageAsText(m, colorScheme: currentColorScheme)
}
// Reset batching flag (already on main actor)
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 {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < ts {
low = mid + 1
} else {
high = mid
}
}
return low
}
/// Check for mentions and send notifications
@@ -6216,3 +6111,37 @@ private func checkForMentions(_ message: BitchatMessage) {
}
}
// End of ChatViewModel class
extension ChatViewModel: PublicMessagePipelineDelegate {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
self.messages = messages
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
normalizedContentKey(content)
}
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
contentLRUMap[key]
}
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
recordContentKey(key, timestamp: timestamp)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
trimMessagesIfNeeded()
}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
isBatchingPublic = isBatching
}
}