From 784a809d0733b8c0532ea394ffd1d5782d6c109d Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 6 Jul 2025 23:44:42 +0200 Subject: [PATCH] Add delivery confirmation and read receipts for private messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- COMMIT_MESSAGE.txt | 18 ++ DELIVERY_BRANCH_SUMMARY.md | 113 +++++++++++ DELIVERY_CONFIRMATION_COMPLETE.md | 66 ------- DELIVERY_CONFIRMATION_SUMMARY.md | 74 ------- DELIVERY_TEST_CHECKLIST.md | 37 ---- bitchat/Services/BluetoothMeshService.swift | 3 +- bitchat/ViewModels/ChatViewModel.swift | 202 +++++++++++++++++--- bitchat/Views/ContentView.swift | 20 +- 8 files changed, 330 insertions(+), 203 deletions(-) create mode 100644 COMMIT_MESSAGE.txt create mode 100644 DELIVERY_BRANCH_SUMMARY.md delete mode 100644 DELIVERY_CONFIRMATION_COMPLETE.md delete mode 100644 DELIVERY_CONFIRMATION_SUMMARY.md delete mode 100644 DELIVERY_TEST_CHECKLIST.md diff --git a/COMMIT_MESSAGE.txt b/COMMIT_MESSAGE.txt new file mode 100644 index 00000000..7ed9bdf8 --- /dev/null +++ b/COMMIT_MESSAGE.txt @@ -0,0 +1,18 @@ +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 \ No newline at end of file diff --git a/DELIVERY_BRANCH_SUMMARY.md b/DELIVERY_BRANCH_SUMMARY.md new file mode 100644 index 00000000..064641d5 --- /dev/null +++ b/DELIVERY_BRANCH_SUMMARY.md @@ -0,0 +1,113 @@ +# Delivery Confirmation Branch - Complete Summary + +## Overview +Implemented a complete end-to-end delivery confirmation system with read receipts for private messages, similar to WhatsApp/Signal. + +## Features Implemented + +### Visual Status Indicators +- ○ Gray circle = Sending +- ✓ Gray single check = Sent (left device) +- ✓✓ Green double checks = Delivered (received by peer) +- ✓✓ Blue double checks (bold) = Read (viewed by peer) +- ⚠ Red triangle = Failed + +### Core Components + +1. **DeliveryStatus Enum** (`BitchatProtocol.swift`) + - States: sending, sent, delivered, read, failed, partiallyDelivered + - Includes recipient info and timestamps + +2. **DeliveryTracker Service** (`DeliveryTracker.swift`) + - Manages pending deliveries + - Handles timeouts (30s default, 60s for favorites) + - Sends delivery status updates via Combine + - Thread-safe with proper locking + +3. **Message Types** (`BitchatProtocol.swift`) + - DeliveryAck (0x0A): Confirms message received + - ReadReceipt (0x0C): Confirms message read + +4. **UI Components** (`ContentView.swift`) + - DeliveryStatusView showing appropriate icons + - Real-time status updates + - Proper alignment with message text + +## Key Technical Achievements + +### 1. Message ID Consistency +- Fixed BinaryProtocol to preserve message IDs +- Ensures ACKs reference the correct message + +### 2. Ephemeral Peer ID Handling +- Messages migrate between peer IDs based on nickname +- Read receipts sent to current peer ID, not old ones +- Chat history preserved across sessions + +### 3. Read Receipt Triggers +- When opening a chat (via button or message send) +- When app becomes active with chat open +- When receiving message while chat is open +- Multiple retry attempts to ensure delivery + +### 4. Thread Safety +- Fixed deadlock in DeliveryTracker +- Proper lock management +- Async message processing + +### 5. Status Consistency +- Prevents status downgrades (read → delivered) +- Handles race conditions gracefully +- Proper status for incoming messages + +## Privacy & Security +- All ACKs and receipts are end-to-end encrypted +- Only the original sender can decrypt confirmations +- No message content in confirmations +- Receipts only sent for private messages + +## Backwards Compatibility ✅ +- **Fully backwards compatible!** +- Old clients ignore new message types +- Mixed client scenarios work correctly +- No breaking changes to core messaging + +## What's NOT Included +1. Read receipts for room/group messages +2. User preference to disable read receipts +3. Batch ACK optimization +4. Persistent storage of delivery status +5. "Last seen" functionality + +## Testing Checklist +- [x] Single message delivery confirmation +- [x] Multiple messages in sequence +- [x] Read receipts when opening existing chat +- [x] Peer ID changes between sessions +- [x] Mixed old/new client communication +- [x] Favorite vs non-favorite timeouts +- [x] App backgrounding/foregrounding + +## Known Limitations +1. Memory usage grows with pending deliveries (no cleanup) +2. Lots of debug logging (should be removed for production) +3. No UI indication of read timestamp +4. Status not shown in notifications/previews + +## Code Quality +- Clean separation of concerns +- Follows existing app patterns +- Proper use of Combine for reactivity +- Well-structured and maintainable + +## Files Modified +- `BitchatProtocol.swift` - Added delivery structures +- `DeliveryTracker.swift` - New service (created) +- `BluetoothMeshService.swift` - ACK/receipt handling +- `ChatViewModel.swift` - Status updates, read triggers +- `ContentView.swift` - UI indicators +- `BinaryProtocol.swift` - Message ID preservation +- `project.yml` - Added new service file + +## Summary +The delivery confirmation system is feature-complete, secure, and backwards compatible. It provides users with real-time feedback about message delivery and read status while maintaining the app's privacy-focused design. The implementation is production-ready with minor cleanup needed (mainly removing debug logs). \ No newline at end of file diff --git a/DELIVERY_CONFIRMATION_COMPLETE.md b/DELIVERY_CONFIRMATION_COMPLETE.md deleted file mode 100644 index 0d41c6cc..00000000 --- a/DELIVERY_CONFIRMATION_COMPLETE.md +++ /dev/null @@ -1,66 +0,0 @@ -# Delivery Confirmation - Complete Implementation - -## The Journey -We implemented a full delivery confirmation system with read receipts. The feature went through several iterations to handle edge cases and the ephemeral nature of peer IDs. - -## Key Challenges Solved - -### 1. Deadlock Issues -- **Problem**: Nested lock acquisition causing macOS app to freeze -- **Solution**: Restructured DeliveryTracker to release locks before calling methods that acquire the same lock - -### 2. Message ID Preservation -- **Problem**: ACKs referenced wrong message IDs -- **Solution**: Fixed BinaryProtocol to properly preserve message IDs during decoding - -### 3. Race Condition with Status Updates -- **Problem**: Read receipts would briefly show blue checkmarks, then revert to green when ACK arrived -- **Solution**: Added logic to prevent downgrading from 'read' to 'delivered' status - -### 4. Ephemeral Peer IDs -- **Problem**: Peer IDs change between sessions, breaking read receipts for existing messages -- **Solution**: - - Match messages by sender nickname in addition to peer ID - - Send read receipts to the CURRENT peer ID, not the old one from the message - - Removed requirement for senderPeerID to exist - -## How It Works Now - -### Sending a Message -1. Alice sends message to Bob -2. Message shows gray circle (○) - "sending" -3. Message is transmitted and shows gray checkmark (✓) - "sent" -4. Bob receives and sends ACK -5. Alice sees green double checkmarks (✓✓) - "delivered" - -### Reading Messages -1. When Bob opens/views the chat with Alice -2. System checks all messages from Alice with "delivered" status -3. Sends read receipts for those messages -4. Alice sees blue double checkmarks (✓✓) - "read" - -### Key Code Components -- `DeliveryTracker.swift` - Manages delivery confirmations and timeouts -- `BitchatProtocol.swift` - Defines delivery status enum and ACK/receipt structures -- `BluetoothMeshService.swift` - Handles sending/receiving ACKs and receipts -- `ChatViewModel.swift` - Updates UI and manages read receipt logic -- `ContentView.swift` - Displays delivery status indicators - -## Visual Indicators -- ○ Gray circle = Sending -- ✓ Gray single check = Sent -- ✓✓ Green double checks = Delivered -- ✓✓ Blue double checks (bold) = Read -- ⚠ Red triangle = Failed - -## Privacy Features -- All ACKs and read receipts are end-to-end encrypted -- Only the original sender can decrypt delivery confirmations -- No message content is included in confirmations - -## Testing the Feature -1. Send a message between two devices -2. Watch status progression on sender's device -3. Open chat on receiver's device -4. Sender should see blue checkmarks -5. Works even if receiver was offline when message was sent \ No newline at end of file diff --git a/DELIVERY_CONFIRMATION_SUMMARY.md b/DELIVERY_CONFIRMATION_SUMMARY.md deleted file mode 100644 index a1289dfb..00000000 --- a/DELIVERY_CONFIRMATION_SUMMARY.md +++ /dev/null @@ -1,74 +0,0 @@ -# Delivery Confirmation Feature Summary - -## Overview -This branch implements a complete delivery confirmation system for bitchat, providing visual feedback for message delivery status and read receipts. - -## Key Components Implemented - -### 1. Delivery Status Tracking -- **DeliveryTracker.swift**: Core service managing delivery confirmations - - Tracks pending deliveries with timeouts (30s for private, 60s for rooms) - - Handles retries for favorite peers (up to 3 attempts) - - Thread-safe implementation with proper lock management - -### 2. Protocol Updates -- **BitchatProtocol.swift**: - - Added `DeliveryStatus` enum with states: sending, sent, delivered, read, failed - - Added `DeliveryAck` structure for acknowledgments - - Added `ReadReceipt` structure for read confirmations - - New message types: `.deliveryAck` and `.readReceipt` - -### 3. UI Implementation -- **ContentView.swift**: - - Added `DeliveryStatusView` component showing: - - Gray circle (○) for sending - - Gray checkmark (✓) for sent - - Green double checkmarks (✓✓) for delivered - - Blue double checkmarks (✓✓) for read - - Red triangle (⚠) for failed - - Proper vertical alignment with message text - -### 4. Message Flow -- **BluetoothMeshService.swift**: - - Added ACK generation and sending - - Added read receipt generation when viewing chats - - Encrypted ACKs for privacy - - Proper message ID handling - -### 5. Critical Bug Fixes - -#### Deadlock Fix -- **Issue**: macOS app freeze when sending private messages -- **Cause**: Nested lock acquisition in DeliveryTracker -- **Fix**: Restructured code to release locks before calling methods that acquire the same lock - -#### Message ID Preservation -- **Issue**: ACKs referenced wrong message IDs -- **Cause**: BinaryProtocol was reading but discarding ID with `let _ =` -- **Fix**: Changed to `let id =` and passed ID to BitchatMessage constructor - -## Technical Details - -### Privacy Considerations -- ACKs and read receipts are encrypted end-to-end -- Only the original sender can decrypt and process them -- No message content is included in confirmations - -### Performance -- Debounced UI updates (100ms) -- Efficient lock usage to prevent deadlocks -- Automatic cleanup of old delivery data - -### Error Handling -- Graceful timeout handling -- Retry logic for favorites -- Clear failure messages - -## Testing -See `test_delivery_confirmation.md` for comprehensive test plan. - -## Future Enhancements -- Group read receipts (show who read in rooms) -- Delivery timestamps in UI -- Persistent delivery status across app restarts -- Network quality indicators \ No newline at end of file diff --git a/DELIVERY_TEST_CHECKLIST.md b/DELIVERY_TEST_CHECKLIST.md deleted file mode 100644 index 1f46f4a0..00000000 --- a/DELIVERY_TEST_CHECKLIST.md +++ /dev/null @@ -1,37 +0,0 @@ -# Test Checklist for Delivery Confirmation - -## Quick Test -1. **Rick sends to Jack** (while Jack is not in the chat) - - ✓ Rick sees gray circle → gray check → green double checks - -2. **Jack opens chat with Rick** - - ✓ Jack should see Rick's message - - ✓ Rick should see blue double checks (this is what we fixed!) - -3. **Jack sends reply to Rick** (while Rick is viewing) - - ✓ Jack sees gray circle → gray check → green double checks → blue double checks - -## Debug Logs to Watch For - -**On Jack's device when opening chat:** -``` -[UI] Selected private chat peer changed to [peer-id] -[UI] Triggering markPrivateMessagesAsRead for peer [peer-id] -[Delivery] Checking N messages in chat with peer [peer-id] (rick) for read receipts -[Delivery] Message [id] from rick, senderPeerID: [old-id], currentPeerID: [new-id], myNickname: jack, status: Delivered to jack -[Delivery] Sending read receipt for message [id] from rick to current peer [peer-id] -[DeliveryTracker] Broadcasting read receipt packet to [peer-id] -``` - -**On Rick's device when receiving read receipt:** -``` -[Delivery] Received READ receipt for message [id] from jack -[Delivery] Updating message [id] to status: read(by: "jack", at: [timestamp]) -[UI] Showing BLUE checkmarks for read status by jack -``` - -## What to Check -- Messages sent before opening chat should turn blue when chat is opened -- Peer ID changes between sessions shouldn't break read receipts -- No race conditions - blue checkmarks should stay blue -- Read receipts work for both real-time and delayed chat opening \ No newline at end of file diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 5c207163..5dfcf12a 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -1581,7 +1581,8 @@ class BluetoothMeshService: NSObject { recipientNickname: message.recipientNickname, senderPeerID: senderID, mentions: message.mentions, - room: message.room + room: message.room, + deliveryStatus: nil // Will be set to .delivered in ChatViewModel ) // Track last message time from this peer diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 2adf2d4c..51f89a8c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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 } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 603ed542..7a800b6b 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -431,7 +431,15 @@ struct ContentView: View { LazyVStack(alignment: .leading, spacing: 2) { let messages: [BitchatMessage] = { if let privatePeer = viewModel.selectedPrivateChatPeer { - return viewModel.getPrivateChatMessages(for: privatePeer) + let msgs = viewModel.getPrivateChatMessages(for: privatePeer) + // Log what we're showing + if !msgs.isEmpty { + print("[UI] Showing \(msgs.count) messages in private chat with \(privatePeer)") + for (idx, msg) in msgs.enumerated() { + print("[UI] Message \(idx): from \(msg.sender), status: \(msg.deliveryStatus?.displayText ?? "none")") + } + } + return msgs } else if let currentRoom = viewModel.currentRoom { return viewModel.getRoomMessages(currentRoom) } else { @@ -543,8 +551,16 @@ struct ContentView: View { // Also check when view appears if let peerID = viewModel.selectedPrivateChatPeer { print("[UI] Messages view appeared with selected peer \(peerID)") + // Try multiple times to ensure read receipts are sent + viewModel.markPrivateMessagesAsRead(from: peerID) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - print("[UI] Triggering markPrivateMessagesAsRead on appear for peer \(peerID)") + print("[UI] Triggering markPrivateMessagesAsRead on appear (0.1s) for peer \(peerID)") + viewModel.markPrivateMessagesAsRead(from: peerID) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + print("[UI] Triggering markPrivateMessagesAsRead on appear (0.5s) for peer \(peerID)") viewModel.markPrivateMessagesAsRead(from: peerID) } }