How BitChat Tracks and Displays Read Receipts and Delivery Status

BitChat uses a dual-state architecture where deliveryStatus tracks network-level message states (sending/delivered/read) inside each BitchatMessage, while a separate sentReadReceipts set in ChatViewModel prevents duplicate local read confirmations from being transmitted.

BitChat implements a robust peer-to-peer messaging protocol that separates network delivery guarantees from local read acknowledgment state. According to the permissionlesstech/bitchat source code, this separation ensures accurate status reporting across BLE and Nostr transports while preventing redundant read receipts after app restarts.

Understanding BitChat's Dual-State Architecture

BitChat maintains two distinct pieces of state for every message to guarantee accurate delivery tracking and receipt acknowledgment.

Delivery Status in BitchatMessage

The delivery state lives in the deliveryStatus property of BitchatMessage located in BitFoundation/BitchatMessage.swift. This property uses the DeliveryStatus enum (defined in localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift) with three possible values:

  • .sending – Message queued for transmission
  • .delivered(to:peerID, at:timestamp) – Transport layer confirmed delivery
  • .read(by:peerID, at:timestamp) – Recipient sent read receipt

When the transport layer receives an ACK or read-receipt event, ChatTransportEventCoordinator translates the transport event into a DeliveryStatus update through ChatDeliveryCoordinator.applyDeliveryEvent(_:forMessageID:peerID:).

Read Receipt Tracking in ChatViewModel

The local acknowledgment state resides in ChatViewModel.swift as a Set<String> named sentReadReceipts. This set tracks which message IDs the local device has already announced as "read" to the peer. The ChatPrivateConversationCoordinator checks this set via hasSentReadReceipt(_:) before generating new read-receipt payloads to avoid network spam.

Message Lifecycle and Delivery Flow

The complete delivery lifecycle follows a three-stage pipeline implemented across the coordinator layer.

1. Initial Send State

When a user sends a message, BitChat creates a new BitchatMessage instance with deliveryStatus: .sending and appends it to the conversation store using appendPrivateMessage or appendPublicMessage. The MessageListView immediately renders a grey clock spinner indicator for this state.

2. Transport ACK Processing

Upon successful network delivery, the BLE or Nostr transport reports a delivery event. The ChatTransportEventCoordinator invokes deliveryCoordinator.applyDeliveryEvent(_:forMessageID:peerID:), which updates the message's deliveryStatus to .delivered(to:peerID, at:timestamp). SwiftUI automatically re-renders the UI because these properties are marked @Published in ChatViewModel.

3. Read Receipt Generation

When the conversation UI becomes visible and displays unread messages, ChatPrivateConversationCoordinator checks sentReadReceipts via ChatViewModel.hasSentReadReceipt(_:). If not previously sent, it constructs a read-receipt packet and calls markReadReceiptSent(_:) to update the local set. The transport layer forwards this receipt, and on the sender's device, the same coordinator flow updates the original message status to .read(by:peerID, at:timestamp).

UI Rendering and SwiftUI Integration

The view layer in bitchat/Views/MessageListView.swift consumes these state changes reactively. The view iterates over viewModel.messages and maps each deliveryStatus to a specific SF Symbols icon:

private func iconName(for status: DeliveryStatus?) -> String {
    switch status {
    case .read:      return "checkmark.double"  // Two blue checkmarks
    case .delivered: return "checkmark"         // Single grey checkmark
    default:         return "clock"            // Spinning clock for sending
    }
}

Because ChatViewModel publishes these properties using @Published, the MessageListView receives automatic updates without manual refresh logic when ChatDeliveryCoordinator modifies message states.

Persistence and State Recovery

BitChat persists the sentReadReceipts set to prevent duplicate read announcements across app launches. The persistence logic resides in ChatViewModel.swift:

var sentReadReceipts: Set<String> = [] {
    didSet {
        guard oldValue != sentReadReceipts else { return }
        if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
            readReceiptsDefaults.set(data, forKey: "sentReadReceipts")
        } else {
            SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
        }
    }
}

The readReceiptsDefaults property accesses UserDefaults through ChatViewModel.defaultReadReceiptsDefaults(). During app initialization, ChatViewModelBootstrapper.loadPersistedReadReceipts restores this set from UserDefaults, ensuring the UI never displays an "unread" badge for messages already acknowledged before a restart.

Practical Implementation Examples

Checking Message Delivery Status Programmatically

To inspect the current delivery state of a specific message in a private conversation:

let msgID = "abc123"
if viewModel.privateChat(peerID).contains(where: { $0.id == msgID }) {
    let status = viewModel.privateChat(peerID).first(where: { $0.id == msgID })?.deliveryStatus
    print("Message status: \(status?.description ?? "unknown")")
}

Manually Triggering Read Receipts

While the UI handles this automatically, you can manually verify and send read receipts when implementing custom conversation coordinators:

if !viewModel.hasSentReadReceipt(msgID) {
    // Records locally and triggers transport transmission
    viewModel.markReadReceiptSent(msgID)
}

Observing Status Changes in Custom Views

For custom SwiftUI components that need to react to delivery status changes:

struct MessageRow: View {
    @ObservedObject var viewModel: ChatViewModel
    let message: BitchatMessage

    var body: some View {
        HStack {
            Text(message.content)
            Spacer()
            Image(systemName: iconName(for: message.deliveryStatus))
        }
    }
    
    private func iconName(for status: DeliveryStatus?) -> String {
        switch status {
        case .read:      return "checkmark.double"
        case .delivered: return "checkmark"
        default:         return "clock"
        }
    }
}

Key Source Files

The read receipt and delivery status system spans these critical files in the permissionlesstech/bitchat repository:

Summary

  • BitChat separates network delivery state (deliveryStatus in BitchatMessage) from local read acknowledgment tracking (sentReadReceipts in ChatViewModel)
  • The DeliveryStatus enum progresses through .sending, .delivered, and .read states as messages traverse the network
  • ChatDeliveryCoordinator and ChatTransportEventCoordinator handle the translation between transport-level ACKs and model updates
  • sentReadReceipts persists to UserDefaults via ChatViewModel to prevent duplicate read receipts across app restarts
  • SwiftUI automatically reflects status changes through @Published properties, rendering clock, single-check, and double-check icons in MessageListView

Frequently Asked Questions

How does BitChat prevent duplicate read receipts from being sent?

BitChat maintains a Set<String> called sentReadReceipts in ChatViewModel that tracks message IDs already acknowledged to the peer. Before sending a new read receipt, ChatPrivateConversationCoordinator checks hasSentReadReceipt(_:). The set automatically persists to UserDefaults using JSON encoding, ensuring the same receipt isn't transmitted again after an app restart.

What triggers a message status to change from delivered to read?

The status transitions from .delivered to .read when the recipient's device sends a read-receipt packet back across the transport layer. On the sender's side, ChatTransportEventCoordinator receives this event and invokes ChatDeliveryCoordinator.applyDeliveryEvent(_:forMessageID:peerID:), which updates the deliveryStatus property to .read(by:peerID, at:timestamp).

Where does BitChat store the delivery status for each message?

Delivery status lives directly on the BitchatMessage model in BitFoundation/BitchatMessage.swift as the deliveryStatus property. This design keeps message state encapsulated with the message data rather than in separate lookup tables, enabling MessageListView to access status directly while iterating over viewModel.messages.

Can the read receipt system work offline?

Yes. The read receipt tracking mechanism stores unsent read intentions locally in sentReadReceipts. When the app reconnects to the transport layer (BLE or Nostr), ChatPrivateConversationCoordinator processes any pending read receipts that accumulated while offline. The UserDefaults persistence ensures this state survives app termination, though actual transmission requires network connectivity.

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 →