mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user