BitChat ConversationStore Architecture and State Management Explained
BitChat stores every chat message in a single, main-actor-owned ConversationStore that centralizes all conversation-level data through a fine-grained observable model and a write-only intent API.
The ConversationStore serves as the single source of truth for both public and private chats in the BitChat decentralized messaging application. According to the permissionlesstech/bitchat source code, this architecture eliminates stale data by enforcing that all mutations flow through controlled intent methods while allowing views to observe individual conversations without triggering unnecessary UI updates across the entire app.
Core Architecture: Single-Writer, Single Source of Truth
At the heart of BitChat's state management is a @MainActor-annotated singleton that owns all mutable collections. This design pattern guarantees thread safety and eliminates data races by ensuring only one entity can modify conversation state.
In bitchat/App/ConversationStore.swift, the store declares its exclusive ownership:
@MainActor
public final class ConversationStore: ObservableObject {
private var conversations: [ConversationID: Conversation] = [:]
private var conversationIDsByMessageID: [String: ConversationID] = [:]
// ...
}
All mutations flow through intent methods — append(_:to:), upsertByID(_:in:), setDeliveryStatus(_:for:in:), and others. This pattern prevents any external component from directly modifying conversation arrays, ensuring the store remains the definitive authority on chat state.
Conversation Objects and Granular Publishing
Each logical chat maps to exactly one Conversation instance, defined in ConversationStore.swift around lines 25-44:
@MainActor
public final class Conversation: ObservableObject {
@Published public private(set) var messages: [BitchatMessage] = []
@Published public var isUnread: Bool = false
private var idToIndex: [String: Int] = [:]
private var indexOffset: Int = 0
private let cap: Int?
}
The granular publishing strategy is critical for performance. Views subscribe to a single Conversation rather than observing the entire store dictionary. When a message arrives in the mesh conversation, only mesh observers receive updates — private chat views remain idle, dramatically reducing SwiftUI recomputation cycles.
The ConversationID type (defined in AppArchitecture.swift) identifies chats by three variants:
.mesh— public mesh-wide broadcasts.geohash(String)— location-based public channels.direct(PeerHandle)— one-to-one private messaging
Selection State and UI Coordination
Beyond message storage, the store manages UI selection state:
@Published public private(set) var activeChannel: ConversationID = .mesh
@Published public private(set) var selectedPrivatePeerID: PeerHandle?
public var selectedConversationID: ConversationID? {
selectedPrivatePeerID.map { .direct($0) } ?? activeChannel
}
Changes flow through explicit methods in ConversationStore.swift (lines 40-48):
setActiveChannel(_:)— switches the public channel viewsetSelectedPrivatePeer(_:)— enters or exits a private conversation
This design keeps UI state centralized, enabling consistent navigation behavior across all app components.
The Intent API: Controlled Mutations
The store exposes six primary intent methods in ConversationStore.swift (lines 93-122):
| Method | Purpose |
|---|---|
append(_:to:) |
Ordered insert with deduplication by ID |
upsertByID(_:in:) |
Replace existing message or append |
setDeliveryStatus(_:for:in:) |
Update status with no-downgrade protection |
markRead(_:) / markUnread(_:) |
Toggle unread flags |
clear(_:) / remove(_:in:) |
Delete messages or entire conversations |
migrateConversation(from:to:) |
Handle geohash proximity transitions |
The no-downgrade rule for delivery status prevents race conditions where an older receipt could overwrite a newer confirmed delivery:
public func setDeliveryStatus(
_ status: DeliveryStatus,
for messageID: String,
in conversationID: ConversationID
) {
// Internal implementation rejects transitions like
// .delivered → .sending
}
Optimized Lookup: Message-to-Conversation Mapping
A store-level dictionary enables O(1) delivery receipt routing without scanning conversations:
private var conversationIDsByMessageID: [String: ConversationID] = [:]
When a message arrives, the store registers its ID (lines 53-61 in ConversationStore.swift). Later, when a receipt acknowledges delivery, the store instantly locates the target conversation — no iteration required.
Change Notifications for Background Services
After each intent completes, the store emits structured change events:
public enum ConversationChange {
case appended(conversationID: ConversationID, message: BitchatMessage)
case updated(conversationID: ConversationID, messageID: String)
case removed(conversationID: ConversationID, messageID: String)
case cleared(conversationID: ConversationID)
}
public let changes = PassthroughSubject<ConversationChange, Never>()
Background services — delivery tracking, sync engines, notification handlers — subscribe to store.changes instead of observing @Published properties. This separation lets UI code use SwiftUI's native observation while specialized consumers receive precise, filtered events.
Memory Management: Cap and Trimming Policy
Each Conversation enforces a configurable cap (e.g., TransportConfig.meshTimelineCap). When exceeded, the oldest messages are trimmed with index preservation:
// From Conversation.swift implementation
private func trimIfNeeded() {
guard let cap = cap, messages.count > cap else { return }
let overflow = messages.count - cap
indexOffset += overflow
messages.removeFirst(overflow)
rebuildIndex()
}
The indexOffset adjustment maintains valid ID-to-index mappings without full rebuilds, keeping message lookup O(1) even after aggressive trimming.
State Flow: End-to-End Message Handling
Understanding how ConversationStore integrates with the broader architecture:
- Transport reception —
PublicMessagePipeline.swiftor private peer handlers receive raw messages - Intent invocation — coordinators call
store.append(message, to: conversationID) - Ordered insertion —
Conversation.insert(_:)places by timestamp, updatesidToIndex, trims if capped - Registration — message ID added to
conversationIDsByMessageID - Dual publication —
@Publishedupdates SwiftUI views;changessubject notifies background services
Because mutations never escape the store, observers always see consistent state immediately after the intent returns.
Development and Diagnostic Support
The store includes invariant checking for development builds (lines 200-229 in ConversationStore.swift):
public func collectInvariantViolations() -> [String] {
var violations: [String] = []
for (id, conversation) in conversations {
// Validates index consistency, cap compliance,
// timestamp ordering, and map coherence
}
return violations
}
These checks catch synchronization bugs during testing without impacting release performance.
Practical Usage Example
import BitChat
// Obtain the store (normally injected by AppRuntime)
let store = ConversationStore()
// Send a public mesh message
let message = BitchatMessage(
id: UUID().uuidString,
timestamp: Date(),
payload: .text("Hello decentralized world!"),
deliveryStatus: .sending
)
store.append(message, to: .mesh)
// Observe specific conversation (efficient SwiftUI integration)
let meshChat = store.conversation(for: .mesh)
let cancellable = meshChat.$messages
.sink { messages in
print("Mesh messages: \(messages.count)")
}
// Confirm delivery when receipt arrives
store.setDeliveryStatus(.delivered, for: message.id, in: .mesh)
// Mark private conversation unread
store.setUnread(true, for: .direct(PeerHandle(id: "bob", routingPeerID: "bob-node")))
Summary
- Single-owner architecture:
@MainActorConversationStoreguarantees thread-safe, consistent state - Intent-only mutations: All writes flow through typed methods; direct mutation is impossible
- Granular observation: Views subscribe to individual
Conversationinstances, not the entire store - O(1) lookups: Dual indexing by message ID eliminates scan operations for receipts and updates
- Dual publication:
@Publishedfor SwiftUI,PassthroughSubjectfor background services - Memory safety: Configurable per-conversation caps with efficient trimming and index preservation
Frequently Asked Questions
How does BitChat prevent race conditions in conversation updates?
The ConversationStore is annotated @MainActor, ensuring all mutations occur on the main thread. Additionally, the no-downgrade rule in setDeliveryStatus(_:for:in:) rejects status updates that would move from a more-confirmed to less-confirmed state, preventing out-of-order receipt processing from corrupting delivery tracking.
Why does BitChat use Conversation objects instead of a flat message array?
Individual Conversation instances enable fine-grained publishing. When a message arrives in one chat, only observers of that specific Conversation receive updates. This architectural choice in ConversationStore.swift (lines 27-30) prevents the cascade of SwiftUI view invalidations that would occur if the entire message dataset were observed together.
How does the message-to-conversation map improve performance?
The conversationIDsByMessageID dictionary provides O(1) lookup for routing delivery receipts. Without this map, confirming message delivery would require scanning every conversation's message array. According to the source in ConversationStore.swift (lines 53-61), this optimization is essential for the high-frequency receipt traffic in mesh networking scenarios.
What happens when a conversation exceeds its message cap?
The Conversation class trims oldest messages while adjusting an indexOffset value. This preserves the validity of the idToIndex map without requiring a full rebuild, maintaining constant-time message lookup even under memory pressure. The cap is configurable per transport type via TransportConfig.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →