Cut public message path over to ConversationStore; delete PublicTimelineStore

Mesh and geohash timelines are now store conversations. All public
mutation sites flow through store intents; PublicMessagePipeline keeps
its 80ms UI batching but commits batches via store appends with each
buffered entry carrying its destination conversation (a mid-batch
channel switch now flushes instead of dropping the buffer).
ChatViewModel.messages becomes a cached get-only view of the active
conversation, invalidated through the change subject. The mesh
late-insert threshold is consciously removed: it only ever ordered the
non-rendered messages copy, so strict timestamp insertion makes the
working set agree with rendered order. PublicTimelineStore and the
per-message full-array legacy sync are deleted; the coalescing bridge
mirrors public conversations for the remaining legacy readers.

pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady;
store.append 237k/s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-11 13:03:07 +02:00
co-authored by Claude Fable 5
parent 879d8cba12
commit 99d1d1dccd
26 changed files with 713 additions and 875 deletions
+139 -53
View File
@@ -119,9 +119,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
/// Read-only derived view of the ACTIVE public channel's conversation in
/// the single-writer `ConversationStore` (migration step 3 shim; views
/// observe `Conversation` objects directly in step 5). Hot for rendering,
/// so the array is cached and invalidated from the store's `changes`
/// subject (filtered to the active conversation) and on channel switches.
/// `objectWillChange` fires on every store change via the sink in `init`.
@MainActor
var messages: [BitchatMessage] {
if let cached = visibleMessagesCache { return cached }
// Read-only lookup (never creates the conversation): this getter
// runs during SwiftUI renders, where mutating the store's
// `@Published` collections would publish mid-view-update.
let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? []
visibleMessagesCache = current
return current
}
private var visibleMessagesCache: [BitchatMessage]?
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var isConnected = false
@Published var nickname: String = "" {
didSet {
@@ -298,9 +313,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
get { conversationStore.activeChannel }
set {
guard conversationStore.activeChannel != newValue else { return }
publicMessagePipeline.updateActiveChannel(newValue)
conversationStore.setActiveChannel(newValue)
synchronizePublicConversationStore(for: newValue)
visibleMessagesCache = nil
synchronizeConversationSelectionStore()
objectWillChange.send()
}
@@ -352,11 +366,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
var timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread {
MainActor.assumeIsolated {
@@ -631,6 +640,83 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
return removed
}
// MARK: - Public Conversation Store Intents
// The sole mutation paths for public (mesh/geohash) message state,
// mirroring the private intents above. The store's per-conversation cap
// and timestamp-ordered insert replace `PublicTimelineStore`'s trim and
// the pipeline's late-insert positioning; the read-only `messages` shim
// above is derived from the same store.
/// Appends a public message in timestamp order. Returns `false` when a
/// message with the same ID is already in that conversation (O(1) dedup
/// via the conversation's ID index).
@MainActor
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
conversations.append(message, to: conversationID)
}
/// Appends a geohash message if absent. Returns `true` when stored
/// (the legacy `PublicTimelineStore.appendIfAbsent` contract).
@MainActor
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
conversations.append(message, to: .geohash(geohash.lowercased()))
}
/// A public (mesh/geohash) channel's full timeline.
@MainActor
func publicMessages(for channel: ChannelID) -> [BitchatMessage] {
conversations.conversation(for: ConversationID(channelID: channel)).messages
}
/// `true` when the conversation contains a message with `messageID`.
@MainActor
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
}
/// Removes a message by ID from whichever public conversation contains
/// it. Returns the removed message, if any.
@MainActor
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
conversations.removePublicMessage(withID: messageID)
}
/// Removes every message matching `predicate` from a geohash
/// conversation (block-user purge).
@MainActor
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate)
}
/// Empties a public conversation's timeline (`/clear`).
@MainActor
func clearPublicConversation(_ conversationID: ConversationID) {
conversations.clear(conversationID)
}
/// Queues a system message for the next geohash channel visit. (Tiny
/// UI-flow queue formerly on `PublicTimelineStore`; it is notice text,
/// not conversation state, so it stays on the owner.)
@MainActor
func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
/// Drains the queued geohash system messages (single consumer:
/// `GeohashSubscriptionManager.switchLocationChannel`).
@MainActor
func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
// Single-writer: mutate only via `queueGeohashSystemMessage(_:)` /
// `drainPendingGeohashSystemMessages()` above.
private var pendingGeohashSystemMessages: [String] = []
// MARK: - Initialization
@MainActor
@@ -717,12 +803,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Republish on every store change so SwiftUI observers of the
// view model refresh. This replaces the UI-update role of the old
// `PrivateChatManager.@Published` dictionaries (their debounced
// Legacy synchronization sinks are gone; the bridge above feeds
// Legacy instead).
// `PrivateChatManager.@Published` dictionaries and the old
// `@Published var messages` (their debounced Legacy synchronization
// sinks are gone; the bridge above feeds Legacy instead). Changes
// touching the ACTIVE public conversation also invalidate the
// derived `messages` cache before observers re-read it.
conversations.changes
.sink { [weak self] _ in
self?.objectWillChange.send()
.sink { [weak self] change in
guard let self else { return }
if self.changeAffectsActivePublicConversation(change) {
self.visibleMessagesCache = nil
}
self.objectWillChange.send()
}
.store(in: &cancellables)
@@ -1008,13 +1100,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
// Clear all messages
messages.removeAll()
timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
conversations.removeAllDirectConversations()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
conversations.clearAll()
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)
_ = keychain.deleteAllKeychainData()
@@ -1190,22 +1280,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
func initializeConversationStore() {
publicConversationCoordinator.initializeConversationStore()
}
@MainActor
func synchronizePublicConversationStore(for channel: ChannelID) {
publicConversationCoordinator.synchronizePublicConversationStore(for: channel)
}
@MainActor
func synchronizePublicConversationStore(forGeohash geohash: String) {
publicConversationCoordinator.synchronizePublicConversationStore(forGeohash: geohash)
}
@MainActor
func synchronizeAllPublicConversationStores() {
publicConversationCoordinator.synchronizeAllPublicConversationStores()
conversationStore.setActiveChannel(activeChannel)
synchronizeConversationSelectionStore()
}
/// Full Legacy-store resynchronization from the new `ConversationStore`.
@@ -1226,15 +1302,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
func trimMessagesIfNeeded() {
if messages.count > maxMessages {
messages = Array(messages.suffix(maxMessages))
}
}
/// Invalidates the derived `messages` cache and notifies observers.
/// (Formerly pulled the channel's timeline into a stored `messages`
/// array; `messages` is now derived from the `ConversationStore`, so
/// only the invalidation remains. The `channel` parameter is kept for
/// call-site compatibility every caller passes the active channel.)
@MainActor
func refreshVisibleMessages(from channel: ChannelID? = nil) {
publicConversationCoordinator.refreshVisibleMessages(from: channel)
visibleMessagesCache = nil
objectWillChange.send()
}
/// `true` when a store change touches the active public conversation
/// (so the derived `messages` cache must be invalidated).
@MainActor
private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool {
let activeID = ConversationID(channelID: activeChannel)
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id),
.removed(let id),
.unreadChanged(let id, _):
return id == activeID
case .migrated(let source, let destination):
return source == activeID || destination == activeID
}
}
@MainActor
@@ -1276,15 +1371,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.clearCurrentPublicTimeline()
}
// MARK: - Message Management
private func addMessage(_ message: BitchatMessage) {
// Check for duplicates
guard !messages.contains(where: { $0.id == message.id }) else { return }
messages.append(message)
trimMessagesIfNeeded()
}
// MARK: - Peer Lookup Helpers
func getPeer(byID peerID: PeerID) -> BitchatPeer? {