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:
ChatViewModel.swift– Central view model holdingsentReadReceiptspersistence and public methodshasSentReadReceiptandmarkReadReceiptSentDeliveryStatus.swift– Enum defining the three delivery stages and associated metadataBitchatMessage.swift– Message model containing thedeliveryStatuspropertyChatDeliveryCoordinator.swift– Applies delivery and read-receipt events to the conversation storeChatTransportEventCoordinator.swift– Receives raw transport events and forwards them to the delivery coordinatorMessageListView.swift– SwiftUI view rendering delivery status icons
Summary
- BitChat separates network delivery state (
deliveryStatusinBitchatMessage) from local read acknowledgment tracking (sentReadReceiptsinChatViewModel) - The
DeliveryStatusenum progresses through.sending,.delivered, and.readstates as messages traverse the network ChatDeliveryCoordinatorandChatTransportEventCoordinatorhandle the translation between transport-level ACKs and model updatessentReadReceiptspersists toUserDefaultsviaChatViewModelto prevent duplicate read receipts across app restarts- SwiftUI automatically reflects status changes through
@Publishedproperties, rendering clock, single-check, and double-check icons inMessageListView
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →