Add read receipts for private messages

- Added ReadReceipt structure and .readReceipt message type
- Added .read delivery status with blue checkmarks in UI
- Send read receipts when viewing a private chat
- Send read receipts immediately for new messages if chat is open
- Update message status from delivered to read when receipt received
This commit is contained in:
jack
2025-07-06 21:49:56 +02:00
parent 2e56245a7e
commit e2b0632879
4 changed files with 143 additions and 0 deletions
+34
View File
@@ -76,6 +76,7 @@ enum MessageType: UInt8 {
case roomRetention = 0x09 // Announce room retention status
case deliveryAck = 0x0A // Acknowledge message received
case deliveryStatusRequest = 0x0B // Request delivery status update
case readReceipt = 0x0C // Message has been read/viewed
}
// Special recipient ID for broadcast messages
@@ -156,11 +157,37 @@ struct DeliveryAck: Codable {
}
}
// Read receipt structure
struct ReadReceipt: Codable {
let originalMessageID: String
let receiptID: String
let readerID: String // Who read it
let readerNickname: String
let timestamp: Date
init(originalMessageID: String, readerID: String, readerNickname: String) {
self.originalMessageID = originalMessageID
self.receiptID = UUID().uuidString
self.readerID = readerID
self.readerNickname = readerNickname
self.timestamp = Date()
}
func encode() -> Data? {
try? JSONEncoder().encode(self)
}
static func decode(from data: Data) -> ReadReceipt? {
try? JSONDecoder().decode(ReadReceipt.self, from: data)
}
}
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
@@ -172,6 +199,8 @@ enum DeliveryStatus: Codable, Equatable {
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
@@ -229,6 +258,7 @@ protocol BitchatDelegate: AnyObject {
// Delivery confirmation methods
func didReceiveDeliveryAck(_ ack: DeliveryAck)
func didReceiveReadReceipt(_ receipt: ReadReceipt)
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
}
@@ -259,6 +289,10 @@ extension BitchatDelegate {
// Default empty implementation
}
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
// Default empty implementation
}
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
// Default empty implementation
}
@@ -707,6 +707,41 @@ class BluetoothMeshService: NSObject {
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to recipientID: String) {
messageQueue.async { [weak self] in
guard let self = self else { return }
// Encode the receipt
guard let receiptData = receipt.encode() else {
print("[DeliveryTracker] Failed to encode read receipt")
return
}
// Encrypt receipt for the original sender
let encryptedPayload: Data
do {
encryptedPayload = try self.encryptionService.encrypt(receiptData, for: recipientID)
} catch {
print("[DeliveryTracker] Failed to encrypt read receipt: \(error)")
return
}
// Create read receipt packet with direct routing to original sender
let packet = BitchatPacket(
type: MessageType.readReceipt.rawValue,
senderID: Data(self.myPeerID.utf8),
recipientID: Data(recipientID.utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encryptedPayload,
signature: nil, // Read receipts don't need signatures
ttl: 3 // Limited TTL for receipts
)
// Send immediately without delay
self.broadcastPacket(packet)
}
}
func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in
guard let self = self else { return }
@@ -1968,6 +2003,32 @@ class BluetoothMeshService: NSObject {
}
}
case .readReceipt:
// Handle read receipt
if let recipientIDData = packet.recipientID,
let recipientID = String(data: recipientIDData.trimmingNullBytes(), encoding: .utf8),
recipientID == myPeerID {
// This read receipt is for us - decrypt it
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8) {
do {
let decryptedData = try encryptionService.decrypt(packet.payload, from: senderID)
if let receipt = ReadReceipt.decode(from: decryptedData) {
// Process the read receipt
DispatchQueue.main.async {
self.delegate?.didReceiveReadReceipt(receipt)
}
}
} catch {
// Failed to decrypt read receipt - might be from unknown sender
}
}
} else if packet.ttl > 0 {
// Relay the read receipt if not for us
var relayPacket = packet
relayPacket.ttl -= 1
self.broadcastPacket(relayPacket)
}
default:
break
}
+38
View File
@@ -886,12 +886,35 @@ class ChatViewModel: ObservableObject {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
// Send read receipts for unread messages from this peer
markPrivateMessagesAsRead(from: peerID)
}
func endPrivateChat() {
selectedPrivateChatPeer = nil
}
private func markPrivateMessagesAsRead(from peerID: String) {
guard let messages = privateChats[peerID] else { return }
// Find messages from the peer that haven't been read yet
for message in messages {
// Only send read receipts for messages from the other peer (not our own)
// and only if the status is delivered (not already read)
if message.senderPeerID == peerID,
case .delivered = message.deliveryStatus {
// Create and send read receipt
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService.myPeerID,
readerNickname: nickname
)
meshService.sendReadReceipt(receipt, to: peerID)
}
}
}
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
return privateChats[peerID] ?? []
}
@@ -1754,6 +1777,16 @@ extension ChatViewModel: BitchatDelegate {
} else {
// We're viewing this chat, make sure unread is cleared
unreadPrivateMessages.remove(peerID)
// Send read receipt immediately since we're viewing the chat
if message.deliveryStatus != nil {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService.myPeerID,
readerNickname: nickname
)
meshService.sendReadReceipt(receipt, to: peerID)
}
}
} else if message.sender == nickname {
// Our own message that was echoed back - ignore it since we already added it locally
@@ -2050,6 +2083,11 @@ extension ChatViewModel: BitchatDelegate {
updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp))
}
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
// Find the message and update its read status
updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp))
}
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
updateMessageDeliveryStatus(messageID, status: status)
}
+10
View File
@@ -1105,6 +1105,16 @@ struct DeliveryStatusView: View {
.foregroundColor(textColor.opacity(0.8))
.help("Delivered to \(nickname)")
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.system(size: 10))
Image(systemName: "checkmark")
.font(.system(size: 10))
}
.foregroundColor(Color.blue)
.help("Read by \(nickname)")
case .failed(let reason):
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 10))