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 view
  • setSelectedPrivatePeer(_:) — 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:

  1. Transport reception — PublicMessagePipeline.swift or private peer handlers receive raw messages
  2. Intent invocation — coordinators call store.append(message, to: conversationID)
  3. Ordered insertion — Conversation.insert(_:) places by timestamp, updates idToIndex, trims if capped
  4. Registration — message ID added to conversationIDsByMessageID
  5. Dual publication — @Published updates SwiftUI views; changes subject 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: @MainActor ConversationStore guarantees thread-safe, consistent state
  • Intent-only mutations: All writes flow through typed methods; direct mutation is impossible
  • Granular observation: Views subscribe to individual Conversation instances, not the entire store
  • O(1) lookups: Dual indexing by message ID eliminates scan operations for receipts and updates
  • Dual publication: @Published for SwiftUI, PassthroughSubject for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →