PeerID 22/n: PrivateChatManager (#800)

* PrivateChatManager: functions to use `PeerID`

* PrivateChatManager: properties to use `PeerID`
This commit is contained in:
Islam
2025-10-14 12:30:18 +02:00
committed by GitHub
parent ad4103bacc
commit 23249f3e41
6 changed files with 150 additions and 133 deletions
+6 -1
View File
@@ -161,9 +161,14 @@ extension PeerID {
id.rangeOfCharacter(from: validCharset.inverted) == nil id.rangeOfCharacter(from: validCharset.inverted) == nil
} }
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex) /// Short routing IDs (exact 16-hex)
var isShort: Bool { var isShort: Bool {
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil bare.count == Constants.hexIDLength && isHex
} }
/// Full Noise key hex (exact 64-hex) /// Full Noise key hex (exact 64-hex)
+1 -1
View File
@@ -42,7 +42,7 @@ final class CommandProcessor {
case .location: return true case .location: return true
} }
}() }()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
+7 -7
View File
@@ -12,9 +12,9 @@ import SwiftUI
/// Manages all private chat functionality /// Manages all private chat functionality
final class PrivateChatManager: ObservableObject { final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:] @Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil @Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -31,11 +31,11 @@ final class PrivateChatManager: ObservableObject {
private let privateChatCap = TransportConfig.privateChatCap private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: PeerID) {
selectedPeer = peerID selectedPeer = peerID
// Store fingerprint for persistence across reconnections // Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) { if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint selectedPeerFingerprint = fingerprint
} }
@@ -55,7 +55,7 @@ final class PrivateChatManager: ObservableObject {
} }
/// Remove duplicate messages by ID and keep chronological order /// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: String) { func sanitizeChat(for peerID: PeerID) {
guard let arr = privateChats[peerID] else { return } guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 { if arr.count <= 1 {
return return
@@ -79,7 +79,7 @@ final class PrivateChatManager: ObservableObject {
} }
/// Mark messages from a peer as read /// Mark messages from a peer as read
func markAsRead(from peerID: String) { func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID) unreadMessages.remove(peerID)
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
+130 -118
View File
@@ -253,21 +253,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs.map(\.id)) } var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs.map(\.id)) }
@Published var allPeers: [BitchatPeer] = [] @Published var allPeers: [BitchatPeer] = []
var privateChats: [String: [BitchatMessage]] { var privateChats: [PeerID: [BitchatMessage]] {
get { privateChatManager.privateChats } get { privateChatManager.privateChats }
set { privateChatManager.privateChats = newValue } set { privateChatManager.privateChats = newValue }
} }
var selectedPrivateChatPeer: String? { var selectedPrivateChatPeer: PeerID? {
get { privateChatManager.selectedPeer } get { privateChatManager.selectedPeer }
set { set {
if let peer = newValue { if let peerID = newValue {
privateChatManager.startChat(with: peer) privateChatManager.startChat(with: peerID)
} else { } else {
privateChatManager.endChat() privateChatManager.endChat()
} }
} }
} }
var unreadPrivateMessages: Set<String> { var unreadPrivateMessages: Set<PeerID> {
get { privateChatManager.unreadMessages } get { privateChatManager.unreadMessages }
set { privateChatManager.unreadMessages = newValue } set { privateChatManager.unreadMessages = newValue }
} }
@@ -286,7 +286,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
.map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 } .sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 { if let target = unreadSorted.first?.0 {
startPrivateChat(with: target) startPrivateChat(with: target.id)
return return
} }
// Otherwise pick most recent private chat overall // Otherwise pick most recent private chat overall
@@ -294,7 +294,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
.sorted { $0.ts > $1.ts } .sorted { $0.ts > $1.ts }
if let target = recent.first?.id { if let target = recent.first?.id {
startPrivateChat(with: target) startPrivateChat(with: target.id)
} }
} }
@@ -1071,6 +1071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
) { ) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID let messageId = pm.messageID
let convKey = PeerID(str: convKey)
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
@@ -1092,7 +1093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false, isRelay: false,
isPrivate: true, isPrivate: true,
recipientNickname: nickname, recipientNickname: nickname,
senderPeerID: PeerID(str: convKey), senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date()) deliveryStatus: .delivered(to: nickname, at: Date())
) )
@@ -1119,7 +1120,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: senderName, from: senderName,
message: pm.content, message: pm.content,
peerID: convKey peerID: convKey.id
) )
} }
} }
@@ -1183,20 +1184,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
/// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs /// Check if a peer has unread messages, including messages stored under stable Noise keys and temporary Nostr peer IDs
@MainActor @MainActor
func hasUnreadMessages(for peerID: String) -> Bool { func hasUnreadMessages(for peerID: String) -> Bool {
let peerID = PeerID(str: peerID)
// First check direct unread messages // First check direct unread messages
if unreadPrivateMessages.contains(peerID) { if unreadPrivateMessages.contains(peerID) {
return true return true
} }
// Check if messages are stored under the stable Noise key hex // Check if messages are stored under the stable Noise key hex
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString() let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if unreadPrivateMessages.contains(noiseKeyHex) { if unreadPrivateMessages.contains(noiseKeyHex) {
return true return true
} }
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
if let nostrHex = peer.nostrPublicKey { if let nostrHex = peer.nostrPublicKey {
let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength)) let convKey = PeerID(nostr_: nostrHex)
if unreadPrivateMessages.contains(convKey) { if unreadPrivateMessages.contains(convKey) {
return true return true
} }
@@ -1204,11 +1206,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Get the peer's nickname to check for temporary Nostr peer IDs // Get the peer's nickname to check for temporary Nostr peer IDs
let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID))?.lowercased() ?? "" let peerNickname = meshService.peerNickname(peerID: peerID)?.lowercased() ?? ""
// Check if any temporary Nostr peer IDs have unread messages from this nickname // Check if any temporary Nostr peer IDs have unread messages from this nickname
for unreadPeerID in unreadPrivateMessages { for unreadPeerID in unreadPrivateMessages {
if unreadPeerID.hasPrefix("nostr_") { if unreadPeerID.isGeoDM {
// Check if messages from this temporary peer match the nickname // Check if messages from this temporary peer match the nickname
if let messages = privateChats[unreadPeerID], if let messages = privateChats[unreadPeerID],
let firstMessage = messages.first, let firstMessage = messages.first,
@@ -1226,8 +1228,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars) // Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
// Ephemeral peer IDs are 8 bytes = 16 hex characters // Ephemeral peer IDs are 8 bytes = 16 hex characters
// Noise public keys are 32 bytes = 64 hex characters // Noise public keys are 32 bytes = 64 hex characters
let peerID = PeerID(str: peerID)
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { if let noisePublicKey = peerID.noiseKey {
// This is a stable Noise key hex (used in private chats) // This is a stable Noise key hex (used in private chats)
// Find the ephemeral peer ID for this Noise key // Find the ephemeral peer ID for this Noise key
let ephemeralPeerID = unifiedPeerService.peers.first { peer in let ephemeralPeerID = unifiedPeerService.peers.first { peer in
@@ -1281,7 +1284,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} else { } else {
// This is an ephemeral peer ID (16 hex chars), use normal toggle // This is an ephemeral peer ID (16 hex chars), use normal toggle
unifiedPeerService.toggleFavorite(PeerID(str: peerID)) unifiedPeerService.toggleFavorite(peerID)
// Trigger UI update // Trigger UI update
objectWillChange.send() objectWillChange.send()
} }
@@ -1331,7 +1334,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
guard let chatFingerprint = selectedPrivateChatFingerprint else { return } guard let chatFingerprint = selectedPrivateChatFingerprint else { return }
// Find current peer ID for the fingerprint // Find current peer ID for the fingerprint
if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { if let currentPeerID = PeerID(str: getCurrentPeerIDForFingerprint(chatFingerprint)) {
// Update the selected peer if it's different // Update the selected peer if it's different
if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID {
@@ -1413,7 +1416,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
updatePrivateChatPeerIfNeeded() updatePrivateChatPeerIfNeeded()
if let selectedPeer = selectedPrivateChatPeer { if let selectedPeer = selectedPrivateChatPeer {
sendPrivateMessage(content, to: selectedPeer) sendPrivateMessage(content, to: selectedPeer.id)
} }
return return
} }
@@ -1767,6 +1770,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
) { ) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID let messageId = pm.messageID
let convKey = PeerID(str: convKey)
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session) SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))", category: .session)
@@ -1789,7 +1793,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false, isRelay: false,
isPrivate: true, isPrivate: true,
recipientNickname: nickname, recipientNickname: nickname,
senderPeerID: PeerID(str: convKey), senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date()) deliveryStatus: .delivered(to: nickname, at: Date())
) )
@@ -1819,7 +1823,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: senderName, from: senderName,
message: pm.content, message: pm.content,
peerID: convKey peerID: convKey.id
) )
} }
@@ -1828,6 +1832,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: String) { private func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return } guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
let convKey = PeerID(str: convKey)
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date()) privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date())
@@ -1840,6 +1845,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: String) { private func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: String) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return } guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
let convKey = PeerID(str: convKey)
if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date()) privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date())
@@ -2001,7 +2007,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Remove geohash DM conversation if exists // Remove geohash DM conversation if exists
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) let convKey = PeerID(nostr_: hex)
if privateChats[convKey] != nil { if privateChats[convKey] != nil {
privateChats.removeValue(forKey: convKey) privateChats.removeValue(forKey: convKey)
unreadPrivateMessages.remove(convKey) unreadPrivateMessages.remove(convKey)
@@ -2207,10 +2213,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func sendPrivateMessage(_ content: String, to peerID: String) { func sendPrivateMessage(_ content: String, to peerID: String) {
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
let peerID = PeerID(str: peerID)
// Check if blocked // Check if blocked
if unifiedPeerService.isBlocked(PeerID(str: peerID)) { if unifiedPeerService.isBlocked(peerID) {
let nickname = meshService.peerNickname(peerID: PeerID(str: peerID)) ?? "user" let nickname = meshService.peerNickname(peerID: peerID) ?? "user"
addSystemMessage( addSystemMessage(
String( String(
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
@@ -2222,20 +2229,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Geohash DM routing: conversation keys start with "nostr_" // Geohash DM routing: conversation keys start with "nostr_"
if peerID.hasPrefix("nostr_") { if peerID.isGeoDM {
sendGeohashDM(content, to: peerID) sendGeohashDM(content, to: peerID.id)
} }
// Determine routing method and recipient nickname // Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID) else { return } guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = meshService.isPeerConnected(PeerID(str: peerID)) let isConnected = meshService.isPeerConnected(peerID)
let isReachable = meshService.isPeerReachable(PeerID(str: peerID)) let isReachable = meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
// Get nickname from various sources // Get nickname from various sources
var recipientNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) var recipientNickname = meshService.peerNickname(peerID: peerID)
if recipientNickname == nil && favoriteStatus != nil { if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname recipientNickname = favoriteStatus?.peerNickname
} }
@@ -2270,7 +2277,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
messageRouter.sendPrivate(content, to: PeerID(str: peerID), recipientNickname: recipientNickname ?? "user", messageID: messageID) messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
// Optimistically mark as sent for both transports; delivery/read will update subsequently // Optimistically mark as sent for both transports; delivery/read will update subsequently
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .sent privateChats[peerID]?[idx].deliveryStatus = .sent
@@ -2301,6 +2308,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return return
} }
let messageID = UUID().uuidString let messageID = UUID().uuidString
let peerID = PeerID(str: peerID)
// Local echo in the DM thread // Local echo in the DM thread
let message = BitchatMessage( let message = BitchatMessage(
@@ -2323,7 +2331,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
objectWillChange.send() objectWillChange.send()
// Resolve recipient hex from mapping // Resolve recipient hex from mapping
guard let recipientHex = nostrKeyMapping[peerID] else { guard let recipientHex = nostrKeyMapping[peerID.id] else {
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed( privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
@@ -2376,8 +2384,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Geohash DMs initiation // MARK: - Geohash DMs initiation
@MainActor @MainActor
func startGeohashDM(withPubkeyHex hex: String) { func startGeohashDM(withPubkeyHex hex: String) {
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength)) let convKey = PeerID(nostr_: hex)
nostrKeyMapping[convKey] = hex nostrKeyMapping[convKey.id] = hex
selectedPrivateChatPeer = convKey selectedPrivateChatPeer = convKey
} }
@@ -2401,6 +2409,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
/// Add a local system message to a private chat (no network send) /// Add a local system message to a private chat (no network send)
@MainActor @MainActor
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) { func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
let peerID = PeerID(str: peerID)
let systemMessage = BitchatMessage( let systemMessage = BitchatMessage(
sender: "system", sender: "system",
content: content, content: content,
@@ -2408,7 +2417,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)), recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID senderPeerID: meshService.myPeerID
) )
if privateChats[peerID] == nil { privateChats[peerID] = [] } if privateChats[peerID] == nil { privateChats[peerID] = [] }
@@ -2457,11 +2466,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if peerID == meshService.myPeerID { if peerID == meshService.myPeerID {
return return
} }
let peerID = PeerID(str: peerID)
let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) ?? "unknown" let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown"
// Check if the peer is blocked // Check if the peer is blocked
if unifiedPeerService.isBlocked(PeerID(str: peerID)) { if unifiedPeerService.isBlocked(peerID) {
addSystemMessage( addSystemMessage(
String( String(
format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"), format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"),
@@ -2473,7 +2483,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Check mutual favorites for offline messaging // Check mutual favorites for offline messaging
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)), if let peer = unifiedPeerService.getPeer(by: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
addSystemMessage( addSystemMessage(
String( String(
@@ -2487,8 +2497,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Consolidate messages from stable Noise key if needed // Consolidate messages from stable Noise key if needed
// This ensures Nostr messages appear when opening a chat with an ephemeral peer ID // This ensures Nostr messages appear when opening a chat with an ephemeral peer ID
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString() let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
// If we have messages stored under the stable Noise key hex but not under the ephemeral ID, // If we have messages stored under the stable Noise key hex but not under the ephemeral ID,
// or if we need to merge them, do so now // or if we need to merge them, do so now
@@ -2518,7 +2528,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender, originalSender: message.originalSender,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : PeerID(str: peerID), // Update peer ID if it's from them senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them
mentions: message.mentions, mentions: message.mentions,
deliveryStatus: message.deliveryStatus deliveryStatus: message.deliveryStatus
) )
@@ -2561,11 +2571,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// These are messages received via Nostr when we didn't know the sender's Noise key // These are messages received via Nostr when we didn't know the sender's Noise key
// They're stored under "nostr_" + first 16 chars of Nostr pubkey // They're stored under "nostr_" + first 16 chars of Nostr pubkey
let currentPeerNickname = peerNickname.lowercased() let currentPeerNickname = peerNickname.lowercased()
var tempPeerIDsToConsolidate: [String] = [] var tempPeerIDsToConsolidate: [PeerID] = []
// Find all temporary Nostr peer IDs that have messages from the same nickname // Find all temporary Nostr peer IDs that have messages from the same nickname
for (storedPeerID, messages) in privateChats { for (storedPeerID, messages) in privateChats {
if storedPeerID.hasPrefix("nostr_") && storedPeerID != peerID { if storedPeerID.isGeoDM && storedPeerID != peerID {
// Check if ALL messages from this temporary peer have the same sender nickname // Check if ALL messages from this temporary peer have the same sender nickname
// This is more reliable than just checking the first message // This is more reliable than just checking the first message
let nicknamesMatch = messages.allSatisfy { msg in let nicknamesMatch = messages.allSatisfy { msg in
@@ -2606,7 +2616,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender, originalSender: message.originalSender,
isPrivate: message.isPrivate, isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname, recipientNickname: message.recipientNickname,
senderPeerID: PeerID(str: peerID), // Update to match current peer senderPeerID: peerID, // Update to match current peer
mentions: message.mentions, mentions: message.mentions,
deliveryStatus: message.deliveryStatus deliveryStatus: message.deliveryStatus
) )
@@ -2635,11 +2645,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.
if !peerID.hasPrefix("nostr_") && !peerID.hasPrefix("nostr:") { if !peerID.isGeoDM && !peerID.isGeoChat {
let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID)) let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState { switch sessionState {
case .none, .failed: case .none, .failed:
meshService.triggerHandshake(with: PeerID(str: peerID)) meshService.triggerHandshake(with: peerID)
default: default:
break break
} }
@@ -2672,7 +2682,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Also mark messages as read for Nostr ACKs // Also mark messages as read for Nostr ACKs
// This ensures read receipts are sent even for consolidated messages // This ensures read receipts are sent even for consolidated messages
markPrivateMessagesAsRead(from: peerID) markPrivateMessagesAsRead(from: peerID.id)
} }
func endPrivateChat() { func endPrivateChat() {
@@ -2744,8 +2754,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate, isKeyUpdate,
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
let oldPeerID = oldKey.hexEncodedString() let oldPeerID = PeerID(hexData: oldKey)
let newPeerID = peerPublicKey.hexEncodedString() let newPeerID = PeerID(hexData: peerPublicKey)
// If we have a private chat open with the old peer ID, update it to the new one // If we have a private chat open with the old peer ID, update it to the new one
if selectedPrivateChatPeer == oldPeerID { if selectedPrivateChatPeer == oldPeerID {
@@ -2769,9 +2779,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
selectedPrivateChatPeer = newPeerID selectedPrivateChatPeer = newPeerID
// Update fingerprint tracking if needed // Update fingerprint tracking if needed
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id)
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint
selectedPrivateChatFingerprint = fingerprint selectedPrivateChatFingerprint = fingerprint
} }
@@ -2794,9 +2804,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Update fingerprint mapping // Update fingerprint mapping
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id)
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint
} }
} }
} }
@@ -2855,10 +2865,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// When app becomes active, send read receipts for visible private chat // When app becomes active, send read receipts for visible private chat
if let peerID = selectedPrivateChatPeer { if let peerID = selectedPrivateChatPeer {
// Try immediately // Try immediately
self.markPrivateMessagesAsRead(from: peerID) self.markPrivateMessagesAsRead(from: peerID.id)
// And again with a delay // And again with a delay
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) {
self.markPrivateMessagesAsRead(from: peerID) self.markPrivateMessagesAsRead(from: peerID.id)
} }
} }
// Subscriptions will be resent after connections come back up // Subscriptions will be resent after connections come back up
@@ -2882,14 +2892,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let peerID = selectedPrivateChatPeer { if let peerID = selectedPrivateChatPeer {
// In private chat - send to the other person // In private chat - send to the other person
if let peerNickname = meshService.peerNickname(peerID: PeerID(str: peerID)) { if let peerNickname = meshService.peerNickname(peerID: peerID) {
// Only send screenshot notification if we have an established session // Only send screenshot notification if we have an established session
// This prevents triggering handshake requests for screenshot notifications // This prevents triggering handshake requests for screenshot notifications
let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID)) let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState { switch sessionState {
case .established: case .established:
// Send the message directly without going through sendPrivateMessage to avoid local echo // Send the message directly without going through sendPrivateMessage to avoid local echo
messageRouter.sendPrivate(screenshotMessage, to: PeerID(str: peerID), recipientNickname: peerNickname, messageID: UUID().uuidString) messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString)
default: default:
// Don't send screenshot notification if no session exists // Don't send screenshot notification if no session exists
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security) SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
@@ -2904,7 +2914,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)), recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID senderPeerID: meshService.myPeerID
) )
var chats = privateChats var chats = privateChats
@@ -3017,11 +3027,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func markPrivateMessagesAsRead(from peerID: String) { func markPrivateMessagesAsRead(from peerID: String) {
let peerID = PeerID(str: peerID)
privateChatManager.markAsRead(from: peerID) privateChatManager.markAsRead(from: peerID)
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
if peerID.hasPrefix("nostr_"), if peerID.isGeoDM,
let recipientHex = nostrKeyMapping[peerID], let recipientHex = nostrKeyMapping[peerID.id],
case .location(let ch) = LocationChannelManager.shared.selectedChannel, case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? [] let messages = privateChats[peerID] ?? []
@@ -3038,18 +3049,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Get the peer's Noise key to check for Nostr messages // Get the peer's Noise key to check for Nostr messages
var noiseKeyHex: String? = nil var noiseKeyHex: PeerID? = nil
var peerNostrPubkey: String? = nil var peerNostrPubkey: String? = nil
// First check if peerID is already a hex Noise key // First check if peerID is already a hex Noise key
if let noiseKey = Data(hexString: peerID), if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
noiseKeyHex = peerID noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} }
// Otherwise get the Noise key from the peer info // Otherwise get the Noise key from the peer info
else if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { else if let peer = unifiedPeerService.getPeer(by: peerID) {
noiseKeyHex = peer.noisePublicKey.hexEncodedString() noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
@@ -3062,7 +3073,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send Nostr read ACKs if peer has Nostr capability // Send Nostr read ACKs if peer has Nostr capability
if peerNostrPubkey != nil { if peerNostrPubkey != nil {
// Check messages under both ephemeral peer ID and stable Noise key // Check messages under both ephemeral peer ID and stable Noise key
let messagesToAck = getPrivateChatMessages(for: peerID) let messagesToAck = getPrivateChatMessages(for: peerID.id)
for message in messagesToAck { for message in messagesToAck {
// Only send read ACKs for messages from the peer (not our own) // Only send read ACKs for messages from the peer (not our own)
@@ -3071,9 +3082,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Skip if we already sent an ACK for this message // Skip if we already sent an ACK for this message
if !sentReadReceipts.contains(message.id) { if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID // Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: PeerID(str: peerID))?.noisePublicKey.hexEncodedString() ?? peerID) let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname) let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID.id, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: PeerID(str: recipPeer)) messageRouter.sendReadReceipt(receipt, to: recipPeer)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
} }
} }
@@ -3084,6 +3095,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] { func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
var combined: [BitchatMessage] = [] var combined: [BitchatMessage] = []
let peerID = PeerID(str: peerID)
// Gather messages under the ephemeral peer ID // Gather messages under the ephemeral peer ID
if let ephemeralMessages = privateChats[peerID] { if let ephemeralMessages = privateChats[peerID] {
@@ -3091,8 +3103,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Also include messages stored under the stable Noise key (Nostr path) // Also include messages stored under the stable Noise key (Nostr path)
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString() let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] {
combined.append(contentsOf: nostrMessages) combined.append(contentsOf: nostrMessages)
} }
@@ -4478,8 +4490,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .delivered: case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) { if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID.id]?[idx].deliveryStatus = .delivered(to: name, at: Date()) privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send() objectWillChange.send()
} }
} }
@@ -4487,8 +4499,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .readReceipt: case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname { if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) { if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID.id]?[idx].deliveryStatus = .read(by: name, at: Date()) privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
objectWillChange.send() objectWillChange.send()
} }
} }
@@ -4667,9 +4679,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
if let current = selectedPrivateChatPeer, current == peerID, if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = derivedStableKeyHex { let stableKeyHex = PeerID(str: derivedStableKeyHex) {
// Migrate messages view context to stable key so header shows favorite + Nostr globe // Migrate messages view context to stable key so header shows favorite + Nostr globe
if let messages = privateChats[peerID.id] { if let messages = privateChats[peerID] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) let existing = Set(privateChats[stableKeyHex]!.map { $0.id })
for msg in messages where !existing.contains(msg.id) { for msg in messages where !existing.contains(msg.id) {
@@ -4682,17 +4694,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: msg.originalSender, originalSender: msg.originalSender,
isPrivate: msg.isPrivate, isPrivate: msg.isPrivate,
recipientNickname: msg.recipientNickname, recipientNickname: msg.recipientNickname,
senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : PeerID(str: stableKeyHex), senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex,
mentions: msg.mentions, mentions: msg.mentions,
deliveryStatus: msg.deliveryStatus deliveryStatus: msg.deliveryStatus
) )
privateChats[stableKeyHex]?.append(updated) privateChats[stableKeyHex]?.append(updated)
} }
privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }
privateChats.removeValue(forKey: peerID.id) privateChats.removeValue(forKey: peerID)
} }
if unreadPrivateMessages.contains(peerID.id) { if unreadPrivateMessages.contains(peerID) {
unreadPrivateMessages.remove(peerID.id) unreadPrivateMessages.remove(peerID)
unreadPrivateMessages.insert(stableKeyHex) unreadPrivateMessages.insert(stableKeyHex)
} }
selectedPrivateChatPeer = stableKeyHex selectedPrivateChatPeer = stableKeyHex
@@ -4707,7 +4719,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Clear sent read receipts for this peer since they'll need to be resent after reconnection // Clear sent read receipts for this peer since they'll need to be resent after reconnection
// Only clear receipts for messages from this specific peer // Only clear receipts for messages from this specific peer
if let messages = privateChats[peerID.id] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer) // Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID { if message.senderPeerID == peerID {
@@ -4715,8 +4727,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
} }
} }
//
} }
func didUpdatePeerList(_ peers: [PeerID]) { func didUpdatePeerList(_ peers: [PeerID]) {
@@ -4790,14 +4800,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
/// Clean up stale unread peer IDs that no longer exist in the peer list /// Clean up stale unread peer IDs that no longer exist in the peer list
@MainActor @MainActor
private func cleanupStaleUnreadPeerIDs() { private func cleanupStaleUnreadPeerIDs() {
let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID.id }) let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID })
let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs)
if !staleIDs.isEmpty { if !staleIDs.isEmpty {
var idsToRemove: [String] = [] var idsToRemove: [PeerID] = []
for staleID in staleIDs { for staleID in staleIDs {
// Don't remove temporary Nostr peer IDs that have messages // Don't remove temporary Nostr peer IDs that have messages
if staleID.hasPrefix("nostr_") { if staleID.isGeoDM {
// Check if we have messages from this temporary peer // Check if we have messages from this temporary peer
if let messages = privateChats[staleID], !messages.isEmpty { if let messages = privateChats[staleID], !messages.isEmpty {
// Keep this ID - it has messages // Keep this ID - it has messages
@@ -4807,7 +4817,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Don't remove stable Noise key hexes (64 char hex strings) that have messages // Don't remove stable Noise key hexes (64 char hex strings) that have messages
// These are used for Nostr messages when peer is offline // These are used for Nostr messages when peer is offline
if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) { if staleID.isNoiseKeyHex {
if let messages = privateChats[staleID], !messages.isEmpty { if let messages = privateChats[staleID], !messages.isEmpty {
// Keep this ID - it's a stable key with messages // Keep this ID - it's a stable key with messages
continue continue
@@ -5125,7 +5135,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown" let senderNickname = (actualSenderNoiseKey != nil) ? (FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey!)?.peerNickname ?? "Unknown") : "Unknown"
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength)) let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
switch noisePayload.type { switch noisePayload.type {
case .privateMessage: case .privateMessage:
@@ -5133,7 +5143,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
noisePayload, noisePayload,
actualSenderNoiseKey: actualSenderNoiseKey, actualSenderNoiseKey: actualSenderNoiseKey,
senderNickname: senderNickname, senderNickname: senderNickname,
targetPeerID: targetPeerID, targetPeerID: targetPeerID.id,
messageTimestamp: messageTimestamp, messageTimestamp: messageTimestamp,
senderPubkey: senderPubkey senderPubkey: senderPubkey
) )
@@ -5196,7 +5206,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if selectedPrivateChatPeer == targetPeerID { if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer, } else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: PeerID(str: selectedPeer)), let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey, let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key { selectedPeerData.noisePublicKey == key {
isViewingThisChat = true isViewingThisChat = true
@@ -5252,7 +5262,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
private func isDuplicateMessage(_ messageId: String, targetPeerID: String) -> Bool { private func isDuplicateMessage(_ messageId: String, targetPeerID: String) -> Bool {
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { if privateChats[PeerID(str: targetPeerID)]?.contains(where: { $0.id == messageId }) == true {
return true return true
} }
for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) {
@@ -5262,6 +5272,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
private func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: String) { private func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: String) {
let targetPeerID = PeerID(str: targetPeerID)
if privateChats[targetPeerID] == nil { if privateChats[targetPeerID] == nil {
privateChats[targetPeerID] = [] privateChats[targetPeerID] = []
} }
@@ -5277,7 +5288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) { private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) {
guard let key, guard let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID ephemeralPeerID != targetPeerID
else { else {
return return
@@ -5312,9 +5323,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) { private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) {
let targetPeerID = PeerID(str: targetPeerID)
unreadPrivateMessages.remove(targetPeerID) unreadPrivateMessages.remove(targetPeerID)
if let key, if let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id { let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {
unreadPrivateMessages.remove(ephemeralPeerID) unreadPrivateMessages.remove(ephemeralPeerID)
} }
if !sentReadReceipts.contains(message.id) { if !sentReadReceipts.contains(message.id) {
@@ -5343,10 +5355,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messageContent: String messageContent: String
) { ) {
guard shouldMarkAsUnread else { return } guard shouldMarkAsUnread else { return }
let targetPeerID = PeerID(str: targetPeerID)
unreadPrivateMessages.insert(targetPeerID) unreadPrivateMessages.insert(targetPeerID)
if let key, if let key,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
ephemeralPeerID != targetPeerID { ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID) unreadPrivateMessages.insert(ephemeralPeerID)
} }
@@ -5354,7 +5366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname, from: senderNickname,
message: messageContent, message: messageContent,
peerID: targetPeerID peerID: targetPeerID.id
) )
} }
} }
@@ -5414,7 +5426,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
} }
messageFound = true messageFound = true
SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.prefix(16))...", category: .session) SecureLogger.info("✅ Updated message \(messageId.prefix(16))... status to \(ackType) in chat \(chatPeerID.id.prefix(16))...", category: .session)
// Don't break - continue to update in all chats where this message exists // Don't break - continue to update in all chats where this message exists
} }
} }
@@ -5515,7 +5527,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// For now, create a temporary peer ID based on Nostr pubkey // For now, create a temporary peer ID based on Nostr pubkey
// This allows the message to be displayed even without Noise key mapping // This allows the message to be displayed even without Noise key mapping
let tempPeerID = "nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength) let tempPeerID = PeerID(nostr_: senderPubkey)
// Check if we're viewing this unknown sender's chat // Check if we're viewing this unknown sender's chat
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
@@ -5554,7 +5566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: nickname, recipientNickname: nickname,
senderPeerID: PeerID(str: tempPeerID), senderPeerID: tempPeerID,
mentions: nil, mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date()) deliveryStatus: .delivered(to: nickname, at: Date())
) )
@@ -5585,7 +5597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname, from: finalSenderNickname,
message: content, message: content,
peerID: tempPeerID peerID: tempPeerID.id
) )
} else { } else {
// Not notifying for old message // Not notifying for old message
@@ -5729,17 +5741,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) { private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) {
let currentFingerprint = getFingerprint(for: peerID) let currentFingerprint = getFingerprint(for: peerID)
let peerID = PeerID(str: peerID)
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = [] var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [String] = [] var oldPeerIDsToRemove: [PeerID] = []
// Only migrate messages from the last 24 hours to prevent old messages from flooding // Only migrate messages from the last 24 hours to prevent old messages from flooding
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in privateChats { for (oldPeerID, messages) in privateChats {
if oldPeerID != peerID { if oldPeerID != peerID {
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id]
// Filter messages to only recent ones // Filter messages to only recent ones
let recentMessages = messages.filter { $0.timestamp > cutoffTime } let recentMessages = messages.filter { $0.timestamp > cutoffTime }
@@ -5825,7 +5837,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func handlePrivateMessage(_ message: BitchatMessage) { private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID?.id ?? getPeerIDForNickname(message.sender) let senderPeerID = message.senderPeerID ?? PeerID(str: getPeerIDForNickname(message.sender))
guard let peerID = senderPeerID else { guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
@@ -5834,18 +5846,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Check if this is a favorite/unfavorite notification // Check if this is a favorite/unfavorite notification
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) handleFavoriteNotificationFromMesh(message.content, from: peerID.id, senderNickname: message.sender)
return // Don't store as a regular message return // Don't store as a regular message
} }
// Migrate chats if needed // Migrate chats if needed
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) migratePrivateChatsIfNeeded(for: peerID.id, senderNickname: message.sender)
// IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer
// This ensures Nostr messages appear in BLE chats // This ensures Nostr messages appear in BLE chats
if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) if peerID.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) { if let peer = unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = peer.noisePublicKey.hexEncodedString() let stableKeyHex = PeerID(hexData: peer.noisePublicKey)
// If we have messages stored under the stable key, merge them // If we have messages stored under the stable key, merge them
if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty { if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty {
@@ -5900,7 +5912,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Handle fingerprint-based chat updates // Handle fingerprint-based chat updates
if let chatFingerprint = selectedPrivateChatFingerprint, if let chatFingerprint = selectedPrivateChatFingerprint,
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID], let senderFingerprint = peerIDToPublicKeyFingerprint[peerID.id],
chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID { chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID {
selectedPrivateChatPeer = peerID selectedPrivateChatPeer = peerID
} }
@@ -5916,7 +5928,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
peerID: peerID peerID: peerID.id
) )
} }
} else { } else {
@@ -5935,25 +5947,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
readerNickname: nickname readerNickname: nickname
) )
let recipientID = message.senderPeerID?.id ?? peerID let recipientID = message.senderPeerID ?? peerID
Task { @MainActor in Task { @MainActor in
var originalTransport: String? = nil var originalTransport: String? = nil
if let noiseKey = Data(hexString: recipientID), if let noiseKey = Data(hexString: recipientID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.peerNostrPublicKey != nil, favoriteStatus.peerNostrPublicKey != nil,
self.meshService.peerNickname(peerID: PeerID(str: recipientID)) == nil { self.meshService.peerNickname(peerID: recipientID) == nil {
originalTransport = "nostr" originalTransport = "nostr"
} }
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) self.sendReadReceipt(receipt, to: recipientID.id, originalTransport: originalTransport)
} }
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
} }
// Mark other messages as read // Mark other messages as read
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) { [weak self] in
self?.markPrivateMessagesAsRead(from: peerID) self?.markPrivateMessagesAsRead(from: peerID.id)
} }
} }
} }
+5 -5
View File
@@ -531,7 +531,7 @@ struct ContentView: View {
} }
.onChange(of: viewModel.privateChats) { _ in .onChange(of: viewModel.privateChats) { _ in
if let peerID = privatePeer, if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID], let messages = viewModel.privateChats[PeerID(str: peerID)],
!messages.isEmpty { !messages.isEmpty {
// If the newest private message is from me, always scroll // If the newest private message is from me, always scroll
let lastMsg = messages.last! let lastMsg = messages.last!
@@ -663,7 +663,7 @@ struct ContentView: View {
(["/w"], nil, "see who's online") (["/w"], nil, "see who's online")
] ]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }() let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
let favInfo: [(commands: [String], syntax: String?, description: String)] = [ let favInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/fav"], "<nickname>", "add to favorites"), (["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites") (["/unfav"], "<nickname>", "remove from favorites")
@@ -747,7 +747,7 @@ struct ContentView: View {
if case .location = locationManager.selectedChannel { return true } if case .location = locationManager.selectedChannel { return true }
return false return false
}() }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
var commandDescriptions = [ var commandDescriptions = [
("/block", String(localized: "content.commands.block", comment: "Description for /block command")), ("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")), ("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
@@ -955,7 +955,7 @@ struct ContentView: View {
private var privateChatSheetView: some View { private var privateChatSheetView: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if let privatePeerID = viewModel.selectedPrivateChatPeer { if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
let headerContext = makePrivateHeaderContext(for: privatePeerID) let headerContext = makePrivateHeaderContext(for: privatePeerID)
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -1021,7 +1021,7 @@ struct ContentView: View {
.background(backgroundColor) .background(backgroundColor)
} }
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate) messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
.background(backgroundColor) .background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
Divider() Divider()
+1 -1
View File
@@ -373,7 +373,7 @@ struct VerificationSheetView: View {
} }
// Optional: Remove verification for selected peer (if verified) // Optional: Remove verification for selected peer (if verified)
if let pid = viewModel.selectedPrivateChatPeer, if let pid = viewModel.selectedPrivateChatPeer?.id,
let fp = viewModel.getFingerprint(for: pid), let fp = viewModel.getFingerprint(for: pid),
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) { Button(action: { viewModel.unverifyFingerprint(for: pid) }) {