diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 0eb515bb..472f2f9a 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -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 } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index b1655223..f3a9615f 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -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 } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a539d460..ee66dc02 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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) } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index e96c73e4..3129dde3 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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))