Files
bitchat/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift
T
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00

1091 lines
47 KiB
Swift

//
// ChatViewModel+PrivateChat.swift
// bitchat
//
// Private chat and media transfer logic for ChatViewModel
//
import Foundation
import Combine
import BitLogger
import BitFoundation
import SwiftUI
extension ChatViewModel {
// MARK: - Private Chat Sending
/// Sends an encrypted private message to a specific peer.
/// - Parameters:
/// - content: The message content to encrypt and send
/// - peerID: The recipient's peer ID
/// - Note: Automatically establishes Noise encryption if not already active
@MainActor
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return }
// Check if blocked
if unifiedPeerService.isBlocked(peerID) {
let nickname = meshService.peerNickname(peerID: peerID) ?? "user"
addSystemMessage(
String(
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
locale: .current,
nickname
)
)
return
}
// Geohash DM routing: conversation keys start with "nostr_"
if peerID.isGeoDM {
sendGeohashDM(content, to: peerID)
return
}
// Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = meshService.isPeerConnected(peerID)
let isReachable = meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
// Get nickname from various sources
var recipientNickname = meshService.peerNickname(peerID: peerID)
if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname
}
recipientNickname = recipientNickname ?? "user"
// Generate message ID
let messageID = UUID().uuidString
// Create the message object
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: meshService.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
// Add to local chat
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
// Trigger UI update for sent message
objectWillChange.send()
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
// Optimistically mark as sent for both transports; delivery/read will update subsequently
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .sent
}
} else {
// Update delivery status to failed
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[index].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
)
}
let name = recipientNickname ?? "user"
addSystemMessage(
String(
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
locale: .current,
name
)
)
}
}
func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let ch) = activeChannel else {
addSystemMessage(
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
)
return
}
let messageID = UUID().uuidString
// Local echo in the DM thread
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: meshService.myPeerID,
deliveryStatus: .sending
)
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(message)
objectWillChange.send()
// Resolve recipient hex from mapping
guard let recipientHex = nostrKeyMapping[peerID] else {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
)
}
return
}
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
)
}
addSystemMessage(
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
return
}
// Send via Nostr using per-geohash identity
do {
let id = try idBridge.deriveIdentity(forGeohash: ch.geohash)
// Prevent messaging ourselves
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
)
}
return
}
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
} catch {
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
)
}
}
}
// MARK: - Private Chat Handling (Geohash/Ephemeral)
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return
}
// Duplicate check
if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in privateChats {
if arr.contains(where: { $0.id == messageId }) {
return
}
}
let senderName = displayNameForNostrPubkey(senderPubkey)
let msg = BitchatMessage(
id: messageId,
sender: senderName,
content: pm.content,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date())
)
if privateChats[convKey] == nil {
privateChats[convKey] = []
}
privateChats[convKey]?.append(msg)
let isViewing = selectedPrivateChatPeer == convKey
let wasReadBefore = sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
unreadPrivateMessages.insert(convKey)
}
// Send READ if viewing this conversation
if isViewing {
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
}
// Notify for truly unread and recent messages when not viewing
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
}
objectWillChange.send()
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date())
objectWillChange.send()
SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date())
objectWillChange.send()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
} else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
}
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !sentGeoDeliveryAcks.contains(messageId) else { return }
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
sentGeoDeliveryAcks.insert(messageId)
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !sentReadReceipts.contains(messageId) else { return }
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
sentReadReceipts.insert(messageId)
}
// MARK: - Media Transfers
private enum MediaSendError: Error {
case encodingFailed
case tooLarge
case copyFailed
}
@MainActor
func sendVoiceNote(at url: URL) {
guard canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
do {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int,
fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
let size = (attrs[.size] as? Int) ?? 0
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
return
}
let data = try Data(contentsOf: url)
let packet = BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "audio/mp4",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.meshService.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run {
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
}
}
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
@MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = selectedPrivateChatPeer
Task.detached(priority: .userInitiated) { [weak self] in
guard let self = self else { return }
var processedURL: URL?
do {
let outputURL = try ImageUtils.processImage(at: sourceURL)
processedURL = outputURL
let data = try Data(contentsOf: outputURL)
guard data.count <= FileTransferLimits.maxImageBytes else {
SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session)
await MainActor.run {
self.addSystemMessage("Image is too large to send.")
}
try? FileManager.default.removeItem(at: outputURL)
return
}
let packet = BitchatFilePacket(
fileName: outputURL.lastPathComponent,
fileSize: UInt64(data.count),
mimeType: "image/jpeg",
content: data
)
guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run {
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.meshService.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run {
self.addSystemMessage("Failed to prepare image for sending.")
}
if let url = processedURL {
try? FileManager.default.removeItem(at: url)
}
}
}
}
@MainActor
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
let timestamp = Date()
let message: BitchatMessage
if let peerID = targetPeer {
message = BitchatMessage(
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nicknameForPeer(peerID),
senderPeerID: meshService.myPeerID,
deliveryStatus: .sending
)
var chats = privateChats
chats[peerID, default: []].append(message)
privateChats = chats
trimMessagesIfNeeded()
} else {
let (displayName, senderPeerID) = currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
timelineStore.append(message, to: activeChannel)
messages = timelineStore.messages(for: activeChannel)
trimMessagesIfNeeded()
}
let key = deduplicationService.normalizedContentKey(message.content)
deduplicationService.recordContentKey(key, timestamp: timestamp)
objectWillChange.send()
return message
}
@MainActor
func registerTransfer(transferId: String, messageID: String) {
transferIdToMessageIDs[transferId, default: []].append(messageID)
messageIDToTransferId[messageID] = transferId
}
func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)"
}
@MainActor
func clearTransferMapping(for messageID: String) {
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
guard var queue = transferIdToMessageIDs[transferId] else { return }
if !queue.isEmpty {
if queue.first == messageID {
queue.removeFirst()
} else if let idx = queue.firstIndex(of: messageID) {
queue.remove(at: idx)
}
}
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
}
@MainActor
func handleMediaSendFailure(messageID: String, reason: String) {
updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
@MainActor
func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
}
func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
let base = try? applicationFilesDirectory(),
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != "." && safeFilename != ".."
else {
return
}
// Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
// Security: Verify target is within expected directory before deletion
guard target.path.hasPrefix(base.path) else { continue }
do {
try FileManager.default.removeItem(at: target)
} catch CocoaError.fileNoSuchFile {
// Expected - file not in this directory
} catch {
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
}
}
}
func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
@MainActor
func cancelMediaSend(messageID: String) {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
meshService.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
@MainActor
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
removeMessage(withID: messageID, cleanupFile: true)
}
// MARK: - Private Chat Handling (Main)
@MainActor
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
// Favorite/unfavorite notifications embedded as private messages
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(messageContent, from: PeerID(hexData: key), senderNickname: senderNickname)
}
return
}
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
return
}
let wasReadBefore = sentReadReceipts.contains(messageId)
// Is viewing?
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
}
// Recency check
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
// Using simplified internal helper in this file (or make the main one internal)
// sendDeliveryAckViaNostrEmbedded is in ChatViewModel+Nostr.swift and is internal.
// However, it was missing in ChatViewModel+Nostr.swift in previous step check?
// Wait, I added `sendDeliveryAckViaNostrEmbedded` to `ChatViewModel+Nostr.swift` in Step 19?
// Let's re-check `ChatViewModel+Nostr.swift` content in my mind.
// I see `sendDeliveryAckViaNostrEmbedded` in `ChatViewModel+Nostr.swift` in the output of step 33.
// So I can call it.
sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: actualSenderNoiseKey
)
if wasReadBefore {
// do nothing
} else if isViewingThisChat {
handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
senderPubkey: senderPubkey
)
} else {
markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
objectWillChange.send()
}
/// Handle incoming private message (Mesh)
@MainActor
func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
return
}
// Check if this is a favorite/unfavorite notification
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
return // Don't store as a regular message
}
// Migrate chats if needed
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
// IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer
// This ensures Nostr messages appear in BLE chats
if peerID.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = PeerID(hexData: peer.noisePublicKey)
// If we have messages stored under the stable key, merge them
if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty {
// Merge messages from stable key into ephemeral peer ID storage
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
// Add any messages that aren't already in the ephemeral storage
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages {
if !existingMessageIds.contains(nostrMessage.id) {
privateChats[peerID]?.append(nostrMessage)
}
}
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// Clean up the stable key storage to avoid duplication
privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session)
}
}
}
// Avoid duplicates
if isDuplicateMessage(message.id, targetPeerID: peerID) {
return
}
// Store the message
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
// Mirror to ephemeral if needed (if we are talking to a stable key peer but have an ephemeral session)
// Actually, logic usually mirrors TO stable key storage if available?
// Or mirrors to ephemeral if we received on stable.
// Let's just use the existing helper which seems to mirror TO ephemeral.
// But we need to get the noise key.
let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
// Notifications and Read Receipts
let isViewing = selectedPrivateChatPeer == peerID
if isViewing {
// Mark read immediately if viewing
// Use the incoming peerID directly - it has the established Noise session.
// Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.
// Use meshService directly (not messageRouter) so it queues if peer disconnects.
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
meshService.sendReadReceipt(receipt, to: peerID)
sentReadReceipts.insert(message.id)
} else {
// Notify
unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
)
}
objectWillChange.send()
}
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
return true
}
for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) {
return true
}
return false
}
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if privateChats[targetPeerID] == nil {
privateChats[targetPeerID] = []
}
if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
privateChats[targetPeerID]?[idx] = message
} else {
privateChats[targetPeerID]?.append(message)
}
// Sanitize to avoid duplicate IDs
privateChatManager.sanitizeChat(for: targetPeerID)
}
@MainActor
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
guard let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID
else {
return
}
if privateChats[ephemeralPeerID] == nil {
privateChats[ephemeralPeerID] = []
}
if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
privateChats[ephemeralPeerID]?[idx] = message
} else {
privateChats[ephemeralPeerID]?.append(message)
}
privateChatManager.sanitizeChat(for: ephemeralPeerID)
}
@MainActor
func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) {
unreadPrivateMessages.remove(targetPeerID)
if let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(message.id) {
if let key {
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
sentReadReceipts.insert(message.id)
} else if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(message.id)
SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session)
}
}
}
@MainActor
func markAsUnreadIfNeeded(
shouldMarkAsUnread: Bool,
targetPeerID: PeerID,
key: Data?,
isRecentMessage: Bool,
senderNickname: String,
messageContent: String
) {
guard shouldMarkAsUnread else { return }
unreadPrivateMessages.insert(targetPeerID)
if let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
}
}
@MainActor
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
// Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..."
let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":")
// Extract Nostr public key if included
var nostrPubkey: String? = nil
if parts.count > 1 {
nostrPubkey = String(parts[1])
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
}
// Get the noise public key for this peer
let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
guard let finalNoiseKey = noiseKey else {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
// Determine prior state to avoid duplicate system messages on repeated notifications
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
// Update the favorite relationship (idempotent storage)
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
)
// If they favorited us and provided their Nostr key, ensure it's stored (log only)
if isFavorite && nostrPubkey != nil {
SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session)
}
// Only show a system message when the state changes, and only in mesh
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
}
/// Process action messages (hugs, slaps) into system messages
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") &&
(message.content.contains("🫂") || message.content.contains("🐟") ||
message.content.contains("took a screenshot"))
if isActionMessage {
return BitchatMessage(
id: message.id,
sender: "system",
content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
}
return message
}
/// Migrate private chats when peer reconnects with new ID
@MainActor
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = getFingerprint(for: peerID)
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = []
// Only migrate messages from the last 24 hours to prevent old messages from flooding
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID {
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID]
// Filter messages to only recent ones
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
// Skip if no recent messages
guard !recentMessages.isEmpty else { continue }
// Check fingerprint match first (most reliable)
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
// Only remove old peer ID if we migrated ALL its messages
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
// Keep old messages in original location but don't show in UI
SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session)
}
SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session)
} else if currentFingerprint == nil || oldFingerprint == nil {
// Check if this chat contains messages with this sender by nickname
let isRelevantChat = recentMessages.contains { msg in
(msg.sender == senderNickname && msg.sender != nickname) ||
(msg.sender == nickname && msg.recipientNickname == senderNickname)
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
// Only remove if all messages were migrated
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
}
SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session)
}
}
}
}
// Remove old peer ID entries
if !oldPeerIDsToRemove.isEmpty {
// Track if we need to update selectedPrivateChatPeer
let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove {
privateChats.removeValue(forKey: oldID)
unreadPrivateMessages.remove(oldID)
// Also clean up fingerprint mapping
if peerIDToPublicKeyFingerprint[oldID] != nil {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldID)
}
}
if needsSelectedUpdate {
selectedPrivateChatPeer = peerID
}
}
// Add migrated messages to new peer ID
if !migratedMessages.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
privateChats[peerID]?.append(contentsOf: migratedMessages)
// Sort by timestamp
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
// De-duplicate just in case
privateChatManager.sanitizeChat(for: peerID)
objectWillChange.send()
}
}
}
@MainActor
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
// Handle both ephemeral peer IDs and Noise key hex strings
var noiseKey: Data?
// First check if peerID is a hex-encoded Noise key
if let hexKey = Data(hexString: peerID.id) {
noiseKey = hexKey
} else {
// It's an ephemeral peer ID, get the Noise key from UnifiedPeerService
if let peer = unifiedPeerService.getPeer(by: peerID) {
noiseKey = peer.noisePublicKey
}
}
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey {
// Send via Nostr for offline peers (using router)
messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
} else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
}
}
/// Check if a message should be blocked based on sender
@MainActor
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) {
// Check mesh/known peers first
if isPeerBlocked(peerID) { return true }
// Check geohash (Nostr) blocks using mapping to full pubkey
if peerID.isGeoChat || peerID.isGeoDM {
if let full = nostrKeyMapping[peerID]?.lowercased() {
if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }
}
}
return false
}
return false
}
}