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