mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:05:19 +00:00
Pass the UserDefaults-backed sentReadReceipts from ChatViewModel to consolidateMessages() to correctly identify already-read messages after app restart. This prevents duplicate read receipts and incorrect unread badges when reopening a chat shortly after reading it. Addresses PR review feedback.
278 lines
11 KiB
Swift
278 lines
11 KiB
Swift
//
|
|
// PrivateChatManager.swift
|
|
// bitchat
|
|
//
|
|
// Manages private chat sessions and messages
|
|
// This is free and unencumbered software released into the public domain.
|
|
//
|
|
|
|
import BitLogger
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// Manages all private chat functionality
|
|
final class PrivateChatManager: ObservableObject {
|
|
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
@Published var selectedPeer: PeerID? = nil
|
|
@Published var unreadMessages: Set<PeerID> = []
|
|
|
|
private var selectedPeerFingerprint: String? = nil
|
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
|
|
|
weak var meshService: Transport?
|
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
|
weak var messageRouter: MessageRouter?
|
|
// Peer service for looking up peer info during consolidation
|
|
weak var unifiedPeerService: UnifiedPeerService?
|
|
|
|
init(meshService: Transport? = nil) {
|
|
self.meshService = meshService
|
|
}
|
|
|
|
// Cap for messages stored per private chat
|
|
private let privateChatCap = TransportConfig.privateChatCap
|
|
|
|
// MARK: - Message Consolidation
|
|
|
|
/// Consolidates messages from different peer ID representations into a single chat.
|
|
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
|
/// - Parameters:
|
|
/// - peerID: The target peer ID to consolidate messages into
|
|
/// - peerNickname: The peer's display name (lowercased for matching)
|
|
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
|
|
/// - Returns: True if any unread messages were found during consolidation
|
|
@MainActor
|
|
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
|
guard let meshService = meshService else { return false }
|
|
var hasUnreadMessages = false
|
|
|
|
// 1. Consolidate from stable Noise key (64-char hex)
|
|
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
|
|
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
|
|
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
for message in nostrMessages {
|
|
if !existingMessageIds.contains(message.id) {
|
|
// Update senderPeerID for correct read receipts
|
|
let updatedMessage = BitchatMessage(
|
|
id: message.id,
|
|
sender: message.sender,
|
|
content: message.content,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus
|
|
)
|
|
privateChats[peerID]?.append(updatedMessage)
|
|
|
|
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
|
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
|
if message.senderPeerID != meshService.myPeerID {
|
|
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
|
hasUnreadMessages = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
|
|
if hasUnreadMessages {
|
|
unreadMessages.insert(peerID)
|
|
} else if unreadMessages.contains(noiseKeyHex) {
|
|
unreadMessages.remove(noiseKeyHex)
|
|
}
|
|
|
|
privateChats.removeValue(forKey: noiseKeyHex)
|
|
}
|
|
}
|
|
|
|
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
|
let normalizedNickname = peerNickname.lowercased()
|
|
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
|
|
for (storedPeerID, messages) in privateChats {
|
|
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
|
if nicknamesMatch && !messages.isEmpty {
|
|
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !tempPeerIDsToConsolidate.isEmpty {
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
|
|
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
var consolidatedCount = 0
|
|
var hadUnreadTemp = false
|
|
|
|
for tempPeerID in tempPeerIDsToConsolidate {
|
|
if unreadMessages.contains(tempPeerID) {
|
|
hadUnreadTemp = true
|
|
}
|
|
|
|
if let tempMessages = privateChats[tempPeerID] {
|
|
for message in tempMessages {
|
|
if !existingMessageIds.contains(message.id) {
|
|
let updatedMessage = BitchatMessage(
|
|
id: message.id,
|
|
sender: message.sender,
|
|
content: message.content,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: peerID,
|
|
mentions: message.mentions,
|
|
deliveryStatus: message.deliveryStatus
|
|
)
|
|
privateChats[peerID]?.append(updatedMessage)
|
|
consolidatedCount += 1
|
|
}
|
|
}
|
|
privateChats.removeValue(forKey: tempPeerID)
|
|
unreadMessages.remove(tempPeerID)
|
|
}
|
|
}
|
|
|
|
if hadUnreadTemp {
|
|
unreadMessages.insert(peerID)
|
|
hasUnreadMessages = true
|
|
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
}
|
|
|
|
if consolidatedCount > 0 {
|
|
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
}
|
|
}
|
|
|
|
return hasUnreadMessages
|
|
}
|
|
|
|
/// Syncs the read receipt tracking between manager and view model for sent messages
|
|
@MainActor
|
|
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
|
guard let messages = privateChats[peerID] else { return }
|
|
|
|
for message in messages {
|
|
if message.sender == nickname {
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .read, .delivered:
|
|
externalReceipts.insert(message.id)
|
|
sentReadReceipts.insert(message.id)
|
|
case .failed, .partiallyDelivered, .sending, .sent:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start a private chat with a peer
|
|
func startChat(with peerID: PeerID) {
|
|
selectedPeer = peerID
|
|
|
|
// Store fingerprint for persistence across reconnections
|
|
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
|
selectedPeerFingerprint = fingerprint
|
|
}
|
|
|
|
// Mark messages as read
|
|
markAsRead(from: peerID)
|
|
|
|
// Initialize chat if needed
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
}
|
|
|
|
/// End the current private chat
|
|
func endChat() {
|
|
selectedPeer = nil
|
|
selectedPeerFingerprint = nil
|
|
}
|
|
|
|
/// Remove duplicate messages by ID and keep chronological order
|
|
func sanitizeChat(for peerID: PeerID) {
|
|
guard let arr = privateChats[peerID] else { return }
|
|
if arr.count <= 1 {
|
|
return
|
|
}
|
|
|
|
var indexByID: [String: Int] = [:]
|
|
indexByID.reserveCapacity(arr.count)
|
|
var deduped: [BitchatMessage] = []
|
|
deduped.reserveCapacity(arr.count)
|
|
|
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
|
if let existing = indexByID[msg.id] {
|
|
deduped[existing] = msg
|
|
} else {
|
|
indexByID[msg.id] = deduped.count
|
|
deduped.append(msg)
|
|
}
|
|
}
|
|
|
|
privateChats[peerID] = deduped
|
|
}
|
|
|
|
/// Mark messages from a peer as read
|
|
func markAsRead(from peerID: PeerID) {
|
|
unreadMessages.remove(peerID)
|
|
|
|
// Send read receipts for unread messages that haven't been sent yet
|
|
if let messages = privateChats[peerID] {
|
|
for message in messages {
|
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
|
sendReadReceipt(for: message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
private func sendReadReceipt(for message: BitchatMessage) {
|
|
guard !sentReadReceipts.contains(message.id),
|
|
let senderPeerID = message.senderPeerID else {
|
|
return
|
|
}
|
|
|
|
sentReadReceipts.insert(message.id)
|
|
|
|
// Create read receipt using the simplified method
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
|
readerNickname: meshService?.myNickname ?? ""
|
|
)
|
|
|
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
|
if let router = messageRouter {
|
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
|
Task { @MainActor in
|
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
|
}
|
|
} else {
|
|
// Fallback: preserve previous behavior
|
|
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
|
}
|
|
}
|
|
}
|