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 roomRetention = 0x09 // Announce room retention status
case deliveryAck = 0x0A // Acknowledge message received case deliveryAck = 0x0A // Acknowledge message received
case deliveryStatusRequest = 0x0B // Request delivery status update case deliveryStatusRequest = 0x0B // Request delivery status update
case readReceipt = 0x0C // Message has been read/viewed
} }
// Special recipient ID for broadcast messages // 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 // Delivery status for messages
enum DeliveryStatus: Codable, Equatable { enum DeliveryStatus: Codable, Equatable {
case sending case sending
case sent // Left our device case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String) case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms case partiallyDelivered(reached: Int, total: Int) // For rooms
@@ -172,6 +199,8 @@ enum DeliveryStatus: Codable, Equatable {
return "Sent" return "Sent"
case .delivered(let nickname, _): case .delivered(let nickname, _):
return "Delivered to \(nickname)" return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason): case .failed(let reason):
return "Failed: \(reason)" return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
@@ -229,6 +258,7 @@ protocol BitchatDelegate: AnyObject {
// Delivery confirmation methods // Delivery confirmation methods
func didReceiveDeliveryAck(_ ack: DeliveryAck) func didReceiveDeliveryAck(_ ack: DeliveryAck)
func didReceiveReadReceipt(_ receipt: ReadReceipt)
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
} }
@@ -259,6 +289,10 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
// Default empty implementation
}
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
// Default empty implementation // 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) { func announcePasswordProtectedRoom(_ room: String, isProtected: Bool = true, creatorID: String? = nil, keyCommitment: String? = nil) {
messageQueue.async { [weak self] in messageQueue.async { [weak self] in
guard let self = self else { return } 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: default:
break break
} }
+38
View File
@@ -886,12 +886,35 @@ class ChatViewModel: ObservableObject {
if privateChats[peerID] == nil { if privateChats[peerID] == nil {
privateChats[peerID] = [] privateChats[peerID] = []
} }
// Send read receipts for unread messages from this peer
markPrivateMessagesAsRead(from: peerID)
} }
func endPrivateChat() { func endPrivateChat() {
selectedPrivateChatPeer = nil 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] { func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
return privateChats[peerID] ?? [] return privateChats[peerID] ?? []
} }
@@ -1754,6 +1777,16 @@ extension ChatViewModel: BitchatDelegate {
} else { } else {
// We're viewing this chat, make sure unread is cleared // We're viewing this chat, make sure unread is cleared
unreadPrivateMessages.remove(peerID) 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 { } else if message.sender == nickname {
// Our own message that was echoed back - ignore it since we already added it locally // 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)) 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) { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
updateMessageDeliveryStatus(messageID, status: status) updateMessageDeliveryStatus(messageID, status: status)
} }
+10
View File
@@ -1105,6 +1105,16 @@ struct DeliveryStatusView: View {
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help("Delivered to \(nickname)") .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): case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 10)) .font(.system(size: 10))