Add delivery confirmation and read receipts for private messages

- Implement full delivery status flow: sending → sent → delivered → read
- Add visual indicators (gray/green/blue checkmarks)
- Create DeliveryTracker service for managing confirmations
- Add end-to-end encrypted ACK and read receipt messages
- Handle ephemeral peer ID changes with message migration
- Fix message ID preservation in BinaryProtocol
- Ensure proper delivery status for incoming messages
- Add multiple triggers for sending read receipts
- Maintain full backwards compatibility with older clients

Visual indicators:
- ○ Gray circle = Sending
- ✓ Gray check = Sent
- ✓✓ Green double checks = Delivered
- ✓✓ Blue double checks = Read
- ⚠ Red triangle = Failed
This commit is contained in:
jack
2025-07-06 23:44:42 +02:00
parent b880c663c2
commit 784a809d07
8 changed files with 330 additions and 203 deletions
+179 -23
View File
@@ -865,6 +865,11 @@ class ChatViewModel: ObservableObject {
guard !content.isEmpty else { return }
guard let recipientNickname = meshService.getPeerNicknames()[peerID] else { return }
// IMPORTANT: When sending a message, it means we're viewing this chat
// Send read receipts for any delivered messages from this peer
print("[Delivery] Sending private message to \(peerID), checking for unread messages first")
markPrivateMessagesAsRead(from: peerID)
// Create the message locally
let message = BitchatMessage(
sender: nickname,
@@ -897,22 +902,85 @@ class ChatViewModel: ObservableObject {
func startPrivateChat(with peerID: String) {
print("[Delivery] Starting private chat with peer \(peerID)")
let peerNickname = meshService.getPeerNicknames()[peerID] ?? "unknown"
print("[Delivery] My nickname: \(nickname), peer nickname: \(peerNickname)")
selectedPrivateChatPeer = peerID
unreadPrivateMessages.remove(peerID)
// Initialize chat history if needed
if privateChats[peerID] == nil {
privateChats[peerID] = []
print("[Delivery] Initialized empty chat history for peer \(peerID)")
} else {
print("[Delivery] Found existing chat history with \(privateChats[peerID]?.count ?? 0) messages for peer \(peerID)")
// Check if we need to migrate messages from an old peer ID
// This happens when peer IDs change between sessions
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
print("[Delivery] No messages under current peer ID \(peerID), checking for messages from nickname \(peerNickname)")
// Look for messages from this nickname under other peer IDs
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [String] = []
for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID {
// Check if any messages in this chat are from the peer's nickname
// Check if this chat contains messages with this peer
let messagesWithPeer = messages.filter { msg in
// Message is FROM the peer to us
(msg.sender == peerNickname && msg.sender != nickname) ||
// OR message is FROM us TO the peer
(msg.sender == nickname && (msg.recipientNickname == peerNickname ||
// Also check if this was a private message in a chat that only has us and one other person
(msg.isPrivate && messages.allSatisfy { m in
m.sender == nickname || m.sender == peerNickname
})))
}
if !messagesWithPeer.isEmpty {
print("[Delivery] Found \(messagesWithPeer.count) messages with \(peerNickname) under old peer ID \(oldPeerID)")
// Check if ALL messages in this chat are between us and this peer
let allMessagesAreWithPeer = messages.allSatisfy { msg in
(msg.sender == peerNickname || msg.sender == nickname) &&
(msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname)
}
if allMessagesAreWithPeer {
// This entire chat history belongs to this peer, migrate it all
print("[Delivery] Migrating entire chat history (\(messages.count) messages) from old peer ID \(oldPeerID) to new peer ID \(peerID)")
migratedMessages.append(contentsOf: messages)
oldPeerIDsToRemove.append(oldPeerID)
}
}
}
}
// Remove old peer ID entries that were fully migrated
for oldPeerID in oldPeerIDsToRemove {
privateChats.removeValue(forKey: oldPeerID)
unreadPrivateMessages.remove(oldPeerID)
}
// Initialize chat history with migrated messages if any
if !migratedMessages.isEmpty {
privateChats[peerID] = migratedMessages.sorted { $0.timestamp < $1.timestamp }
print("[Delivery] Migrated \(migratedMessages.count) messages to peer \(peerID)")
} else {
privateChats[peerID] = []
print("[Delivery] Initialized empty chat history for peer \(peerID)")
}
}
let messages = privateChats[peerID] ?? []
print("[Delivery] Chat with peer \(peerID) now has \(messages.count) messages")
for (index, msg) in messages.enumerated() {
print("[Delivery] Message \(index): from \(msg.sender), status: \(msg.deliveryStatus?.displayText ?? "none")")
}
// Send read receipts for unread messages from this peer
// Add a small delay to ensure UI has updated
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
print("[Delivery] Delayed call to markPrivateMessagesAsRead for peer \(peerID)")
self?.markPrivateMessagesAsRead(from: peerID)
}
// Also try immediately in case messages are already there
markPrivateMessagesAsRead(from: peerID)
}
func endPrivateChat() {
@@ -922,32 +990,69 @@ class ChatViewModel: ObservableObject {
@objc private func appDidBecomeActive() {
// When app becomes active, send read receipts for visible private chat
if let peerID = selectedPrivateChatPeer {
print("[Delivery] App became active with selectedPrivateChatPeer = \(peerID)")
// Try immediately
self.markPrivateMessagesAsRead(from: peerID)
// And again with a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
print("[Delivery] App became active, checking for messages to mark as read in chat with \(peerID)")
print("[Delivery] App became active (delayed), checking for messages to mark as read in chat with \(peerID)")
self.markPrivateMessagesAsRead(from: peerID)
}
}
}
func markPrivateMessagesAsRead(from peerID: String) {
guard let messages = privateChats[peerID] else {
print("[Delivery] No messages found for peer \(peerID)")
// Get the nickname for this peer
let peerNickname = meshService.getPeerNicknames()[peerID] ?? ""
print("[Delivery] ========== markPrivateMessagesAsRead START ==========")
print("[Delivery] Called for peer \(peerID) (\(peerNickname))")
print("[Delivery] My nickname: \(nickname), My peerID: \(meshService.myPeerID)")
// First ensure we have the latest messages (in case of migration)
if let messages = privateChats[peerID], !messages.isEmpty {
print("[Delivery] Found \(messages.count) messages in privateChats[\(peerID)]")
} else {
print("[Delivery] No messages found in privateChats[\(peerID)], checking all chats for messages from \(peerNickname)")
// Look through ALL private chats to find messages from this nickname
for (chatPeerID, chatMessages) in privateChats {
let relevantMessages = chatMessages.filter { msg in
msg.sender == peerNickname && msg.sender != nickname
}
if !relevantMessages.isEmpty {
print("[Delivery] Found \(relevantMessages.count) messages from \(peerNickname) in privateChats[\(chatPeerID)]")
}
}
}
guard let messages = privateChats[peerID], !messages.isEmpty else {
print("[Delivery] No messages to process for peer \(peerID)")
print("[Delivery] ========== markPrivateMessagesAsRead END (no messages) ==========")
return
}
// Get the nickname for this peer
let peerNickname = meshService.getPeerNicknames()[peerID] ?? ""
print("[Delivery] Checking \(messages.count) messages in chat with peer \(peerID) (\(peerNickname)) for read receipts")
print("[Delivery] Processing \(messages.count) messages in chat with peer \(peerID) (\(peerNickname)) for read receipts")
// Find messages from the peer that haven't been read yet
for message in messages {
var readReceiptsSent = 0
for (index, message) in messages.enumerated() {
// Only send read receipts for messages from the other peer (not our own)
print("[Delivery] Message \(message.id) from \(message.sender), senderPeerID: \(message.senderPeerID ?? "nil"), currentPeerID: \(peerID), myNickname: \(nickname), status: \(message.deliveryStatus?.displayText ?? "none")")
// Check multiple conditions to ensure we catch all messages from the peer
let isOurMessage = message.sender == nickname
let isFromPeerByNickname = !peerNickname.isEmpty && message.sender == peerNickname
let isFromPeerByID = message.senderPeerID == peerID
let isPrivateToUs = message.isPrivate && message.recipientNickname == nickname
// Check if this is a message FROM the other person TO us
// Match by sender nickname since peer IDs change between sessions
let isFromPeer = message.sender == peerNickname || message.senderPeerID == peerID
if message.sender != nickname && isFromPeer {
// This is a message FROM the peer if it's not from us AND (matches nickname OR peer ID OR is private to us)
let isFromPeer = !isOurMessage && (isFromPeerByNickname || isFromPeerByID || isPrivateToUs)
print("[Delivery] Message \(index): id=\(message.id)")
print("[Delivery] from=\(message.sender), recipientNickname=\(message.recipientNickname ?? "nil")")
print("[Delivery] senderPeerID=\(message.senderPeerID ?? "nil"), currentPeerID=\(peerID)")
print("[Delivery] isPrivate=\(message.isPrivate), isOurMessage=\(isOurMessage), isFromPeer=\(isFromPeer)")
print("[Delivery] status=\(message.deliveryStatus?.displayText ?? "none")")
if isFromPeer {
if let status = message.deliveryStatus {
switch status {
case .sent, .delivered:
@@ -959,15 +1064,14 @@ class ChatViewModel: ObservableObject {
readerNickname: nickname
)
meshService.sendReadReceipt(receipt, to: peerID)
print("[Delivery] Sending read receipt for message \(message.id) from \(message.sender) to current peer \(peerID)")
print("[Delivery] Sending read receipt for message \(message.id) from \(message.sender) to current peer \(peerID)")
readReceiptsSent += 1
case .read:
// Already read, no need to send another receipt
print("[Delivery] Message \(message.id) already marked as read")
break
default:
// Message not yet delivered, can't mark as read
print("[Delivery] Message \(message.id) has status \(status.displayText), not sending read receipt")
break
}
} else {
// No delivery status - this might be an older message
@@ -979,10 +1083,16 @@ class ChatViewModel: ObservableObject {
readerNickname: nickname
)
meshService.sendReadReceipt(receipt, to: peerID)
print("[Delivery] Sending read receipt for old message \(message.id) to current peer \(peerID)")
print("[Delivery] Sending read receipt for old message \(message.id) to current peer \(peerID)")
readReceiptsSent += 1
}
} else {
print("[Delivery] Skipping message \(message.id) - it's from us (\(nickname))")
}
}
print("[Delivery] Sent \(readReceiptsSent) read receipts to peer \(peerID)")
print("[Delivery] ========== markPrivateMessagesAsRead END ==========")
}
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
@@ -1837,10 +1947,56 @@ extension ChatViewModel: BitchatDelegate {
if let peerID = senderPeerID {
// Message from someone else
// First check if we need to migrate existing messages from this sender
let senderNickname = message.sender
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
// Check if we have messages from this nickname under a different peer ID
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [String] = []
for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID {
// Check if this chat contains messages with this sender
let isRelevantChat = messages.contains { msg in
(msg.sender == senderNickname && msg.sender != nickname) ||
(msg.sender == nickname && msg.recipientNickname == senderNickname)
}
if isRelevantChat {
print("[Delivery] Found existing chat with \(senderNickname) under old peer ID \(oldPeerID), migrating to \(peerID)")
migratedMessages.append(contentsOf: messages)
oldPeerIDsToRemove.append(oldPeerID)
}
}
}
// Remove old peer ID entries
for oldPeerID in oldPeerIDsToRemove {
privateChats.removeValue(forKey: oldPeerID)
unreadPrivateMessages.remove(oldPeerID)
}
// Initialize with migrated messages
privateChats[peerID] = migratedMessages
}
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
// Fix delivery status for incoming messages
var messageToStore = message
if message.sender != nickname {
// This is an incoming message - it should NOT have "sending" status
if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending {
// Mark it as delivered since we received it
messageToStore.deliveryStatus = .delivered(to: nickname, at: Date())
print("[Delivery] Setting delivery status for incoming message \(message.id) to delivered (was: \(message.deliveryStatus?.displayText ?? "nil"))")
}
}
privateChats[peerID]?.append(messageToStore)
// Sort messages by timestamp to ensure proper ordering
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }