# How BitChat Tracks and Displays Read Receipts and Delivery Status

> Learn how BitChat tracks and displays read receipts and delivery status. Discover its dual-state architecture for efficient message tracking.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: internals
- Published: 2026-07-27

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/BitFoundation/BitchatMessage.swift). This property uses the `DeliveryStatus` enum (defined in [`localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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:

```swift
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`](https://github.com/permissionlesstech/bitchat/blob/main/ChatViewModel.swift):

```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:

```swift
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:

```swift
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:

```swift
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:

- **[`ChatViewModel.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ChatViewModel.swift)** – Central view model holding `sentReadReceipts` persistence and public methods `hasSentReadReceipt` and `markReadReceiptSent`
- **[`DeliveryStatus.swift`](https://github.com/permissionlesstech/bitchat/blob/main/DeliveryStatus.swift)** – Enum defining the three delivery stages and associated metadata
- **[`BitchatMessage.swift`](https://github.com/permissionlesstech/bitchat/blob/main/BitchatMessage.swift)** – Message model containing the `deliveryStatus` property
- **[`ChatDeliveryCoordinator.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ChatDeliveryCoordinator.swift)** – Applies delivery and read-receipt events to the conversation store
- **[`ChatTransportEventCoordinator.swift`](https://github.com/permissionlesstech/bitchat/blob/main/ChatTransportEventCoordinator.swift)** – Receives raw transport events and forwards them to the delivery coordinator
- **[`MessageListView.swift`](https://github.com/permissionlesstech/bitchat/blob/main/MessageListView.swift)** – SwiftUI view rendering delivery status icons

## 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`](https://github.com/permissionlesstech/bitchat/blob/main/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.