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
}
/// Returns true if the `bare` id is all hex
var isHex: Bool {
bare.allSatisfy { $0.isHexDigit }
}
/// Short routing IDs (exact 16-hex)
var isShort: Bool {
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
bare.count == Constants.hexIDLength && isHex
}
/// Full Noise key hex (exact 64-hex)
+1 -1
View File
@@ -42,7 +42,7 @@ final class CommandProcessor {
case .location: return true
}
}()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
switch cmd {
case "/m", "/msg":
+7 -7
View File
@@ -12,9 +12,9 @@ import SwiftUI
/// Manages all private chat functionality
final class PrivateChatManager: ObservableObject {
@Published var privateChats: [String: [BitchatMessage]] = [:]
@Published var selectedPeer: String? = nil
@Published var unreadMessages: Set<String> = []
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<PeerID> = []
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -31,11 +31,11 @@ final class PrivateChatManager: ObservableObject {
private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer
func startChat(with peerID: String) {
func startChat(with peerID: PeerID) {
selectedPeer = peerID
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint
}
@@ -55,7 +55,7 @@ final class PrivateChatManager: ObservableObject {
}
/// 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 }
if arr.count <= 1 {
return
@@ -79,7 +79,7 @@ final class PrivateChatManager: ObservableObject {
}
/// Mark messages from a peer as read
func markAsRead(from peerID: String) {
func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID)
// 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
var connectedPeers: [String] { Array(unifiedPeerService.connectedPeerIDs.map(\.id)) }
@Published var allPeers: [BitchatPeer] = []
var privateChats: [String: [BitchatMessage]] {
var privateChats: [PeerID: [BitchatMessage]] {
get { privateChatManager.privateChats }
set { privateChatManager.privateChats = newValue }
}
var selectedPrivateChatPeer: String? {
var selectedPrivateChatPeer: PeerID? {
get { privateChatManager.selectedPeer }
set {
if let peer = newValue {
privateChatManager.startChat(with: peer)
if let peerID = newValue {
privateChatManager.startChat(with: peerID)
} else {
privateChatManager.endChat()
}
}
}
var unreadPrivateMessages: Set<String> {
var unreadPrivateMessages: Set<PeerID> {
get { privateChatManager.unreadMessages }
set { privateChatManager.unreadMessages = newValue }
}
@@ -286,7 +286,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
.map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
startPrivateChat(with: target.id)
return
}
// 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) }
.sorted { $0.ts > $1.ts }
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 }
let messageId = pm.messageID
let convKey = PeerID(str: convKey)
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
@@ -1092,7 +1093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: PeerID(str: convKey),
senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -1119,7 +1120,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
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
@MainActor
func hasUnreadMessages(for peerID: String) -> Bool {
let peerID = PeerID(str: peerID)
// First check direct unread messages
if unreadPrivateMessages.contains(peerID) {
return true
}
// Check if messages are stored under the stable Noise key hex
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if unreadPrivateMessages.contains(noiseKeyHex) {
return true
}
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
if let nostrHex = peer.nostrPublicKey {
let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength))
let convKey = PeerID(nostr_: nostrHex)
if unreadPrivateMessages.contains(convKey) {
return true
}
@@ -1204,11 +1206,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// 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
for unreadPeerID in unreadPrivateMessages {
if unreadPeerID.hasPrefix("nostr_") {
if unreadPeerID.isGeoDM {
// Check if messages from this temporary peer match the nickname
if let messages = privateChats[unreadPeerID],
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)
// Ephemeral peer IDs are 8 bytes = 16 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)
// Find the ephemeral peer ID for this Noise key
let ephemeralPeerID = unifiedPeerService.peers.first { peer in
@@ -1281,7 +1284,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
} else {
// This is an ephemeral peer ID (16 hex chars), use normal toggle
unifiedPeerService.toggleFavorite(PeerID(str: peerID))
unifiedPeerService.toggleFavorite(peerID)
// Trigger UI update
objectWillChange.send()
}
@@ -1331,7 +1334,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
guard let chatFingerprint = selectedPrivateChatFingerprint else { return }
// 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
if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID {
@@ -1413,7 +1416,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
updatePrivateChatPeerIfNeeded()
if let selectedPeer = selectedPrivateChatPeer {
sendPrivateMessage(content, to: selectedPeer)
sendPrivateMessage(content, to: selectedPeer.id)
}
return
}
@@ -1767,6 +1770,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let convKey = PeerID(str: convKey)
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,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: PeerID(str: convKey),
senderPeerID: convKey,
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -1819,7 +1823,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
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) {
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 }) {
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) {
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 }) {
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
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
let convKey = PeerID(nostr_: hex)
if privateChats[convKey] != nil {
privateChats.removeValue(forKey: convKey)
unreadPrivateMessages.remove(convKey)
@@ -2207,10 +2213,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func sendPrivateMessage(_ content: String, to peerID: String) {
guard !content.isEmpty else { return }
let peerID = PeerID(str: peerID)
// Check if blocked
if unifiedPeerService.isBlocked(PeerID(str: peerID)) {
let nickname = meshService.peerNickname(peerID: PeerID(str: peerID)) ?? "user"
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"),
@@ -2222,20 +2229,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Geohash DM routing: conversation keys start with "nostr_"
if peerID.hasPrefix("nostr_") {
sendGeohashDM(content, to: peerID)
if peerID.isGeoDM {
sendGeohashDM(content, to: peerID.id)
}
// Determine routing method and recipient nickname
guard let noiseKey = Data(hexString: peerID) else { return }
let isConnected = meshService.isPeerConnected(PeerID(str: peerID))
let isReachable = meshService.isPeerReachable(PeerID(str: peerID))
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(str: peerID))
var recipientNickname = meshService.peerNickname(peerID: peerID)
if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname
}
@@ -2270,7 +2277,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
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
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .sent
@@ -2301,6 +2308,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
let messageID = UUID().uuidString
let peerID = PeerID(str: peerID)
// Local echo in the DM thread
let message = BitchatMessage(
@@ -2323,7 +2331,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
objectWillChange.send()
// 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 }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
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
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
let convKey = "nostr_" + String(hex.prefix(TransportConfig.nostrConvKeyPrefixLength))
nostrKeyMapping[convKey] = hex
let convKey = PeerID(nostr_: hex)
nostrKeyMapping[convKey.id] = hex
selectedPrivateChatPeer = convKey
}
@@ -2401,6 +2409,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
/// Add a local system message to a private chat (no network send)
@MainActor
func addLocalPrivateSystemMessage(_ content: String, to peerID: String) {
let peerID = PeerID(str: peerID)
let systemMessage = BitchatMessage(
sender: "system",
content: content,
@@ -2408,7 +2417,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)),
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
)
if privateChats[peerID] == nil { privateChats[peerID] = [] }
@@ -2457,11 +2466,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if peerID == meshService.myPeerID {
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
if unifiedPeerService.isBlocked(PeerID(str: peerID)) {
if unifiedPeerService.isBlocked(peerID) {
addSystemMessage(
String(
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
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)),
if let peer = unifiedPeerService.getPeer(by: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
addSystemMessage(
String(
@@ -2487,8 +2497,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Consolidate messages from stable Noise key if needed
// This ensures Nostr messages appear when opening a chat with an ephemeral peer ID
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
// 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
@@ -2518,7 +2528,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
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,
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
// They're stored under "nostr_" + first 16 chars of Nostr pubkey
let currentPeerNickname = peerNickname.lowercased()
var tempPeerIDsToConsolidate: [String] = []
var tempPeerIDsToConsolidate: [PeerID] = []
// Find all temporary Nostr peer IDs that have messages from the same nickname
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
// This is more reliable than just checking the first message
let nicknamesMatch = messages.allSatisfy { msg in
@@ -2606,7 +2616,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: PeerID(str: peerID), // Update to match current peer
senderPeerID: peerID, // Update to match current peer
mentions: message.mentions,
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.
if !peerID.hasPrefix("nostr_") && !peerID.hasPrefix("nostr:") {
let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID))
if !peerID.isGeoDM && !peerID.isGeoChat {
let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState {
case .none, .failed:
meshService.triggerHandshake(with: PeerID(str: peerID))
meshService.triggerHandshake(with: peerID)
default:
break
}
@@ -2672,7 +2682,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Also mark messages as read for Nostr ACKs
// This ensures read receipts are sent even for consolidated messages
markPrivateMessagesAsRead(from: peerID)
markPrivateMessagesAsRead(from: peerID.id)
}
func endPrivateChat() {
@@ -2744,8 +2754,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate,
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
let oldPeerID = oldKey.hexEncodedString()
let newPeerID = peerPublicKey.hexEncodedString()
let oldPeerID = PeerID(hexData: oldKey)
let newPeerID = PeerID(hexData: peerPublicKey)
// If we have a private chat open with the old peer ID, update it to the new one
if selectedPrivateChatPeer == oldPeerID {
@@ -2769,9 +2779,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
selectedPrivateChatPeer = newPeerID
// Update fingerprint tracking if needed
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id)
peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint
selectedPrivateChatFingerprint = fingerprint
}
@@ -2794,9 +2804,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Update fingerprint mapping
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID)
peerIDToPublicKeyFingerprint[newPeerID] = fingerprint
if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id] {
peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID.id)
peerIDToPublicKeyFingerprint[newPeerID.id] = fingerprint
}
}
}
@@ -2855,10 +2865,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// When app becomes active, send read receipts for visible private chat
if let peerID = selectedPrivateChatPeer {
// Try immediately
self.markPrivateMessagesAsRead(from: peerID)
self.markPrivateMessagesAsRead(from: peerID.id)
// And again with a delay
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
@@ -2882,14 +2892,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let peerID = selectedPrivateChatPeer {
// 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
// This prevents triggering handshake requests for screenshot notifications
let sessionState = meshService.getNoiseSessionState(for: PeerID(str: peerID))
let sessionState = meshService.getNoiseSessionState(for: peerID)
switch sessionState {
case .established:
// 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:
// Don't send screenshot notification if no session exists
SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security)
@@ -2904,7 +2914,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: meshService.peerNickname(peerID: PeerID(str: peerID)),
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
)
var chats = privateChats
@@ -3017,11 +3027,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func markPrivateMessagesAsRead(from peerID: String) {
let peerID = PeerID(str: peerID)
privateChatManager.markAsRead(from: peerID)
// Handle GeoDM (nostr_*) read receipts directly via per-geohash identity
if peerID.hasPrefix("nostr_"),
let recipientHex = nostrKeyMapping[peerID],
if peerID.isGeoDM,
let recipientHex = nostrKeyMapping[peerID.id],
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? []
@@ -3038,18 +3049,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Get the peer's Noise key to check for Nostr messages
var noiseKeyHex: String? = nil
var noiseKeyHex: PeerID? = nil
var peerNostrPubkey: String? = nil
// 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) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
}
// Otherwise get the Noise key from the peer info
else if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) {
noiseKeyHex = peer.noisePublicKey.hexEncodedString()
else if let peer = unifiedPeerService.getPeer(by: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
@@ -3062,7 +3073,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send Nostr read ACKs if peer has Nostr capability
if peerNostrPubkey != nil {
// 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 {
// 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
if !sentReadReceipts.contains(message.id) {
// 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)
messageRouter.sendReadReceipt(receipt, to: PeerID(str: recipPeer))
messageRouter.sendReadReceipt(receipt, to: recipPeer)
sentReadReceipts.insert(message.id)
}
}
@@ -3084,6 +3095,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
let peerID = PeerID(str: peerID)
// Gather messages under the ephemeral peer ID
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)
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] {
combined.append(contentsOf: nostrMessages)
}
@@ -4478,8 +4490,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID.id]?[idx].deliveryStatus = .delivered(to: name, at: Date())
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send()
}
}
@@ -4487,8 +4499,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
if let messages = privateChats[peerID.id], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID.id]?[idx].deliveryStatus = .read(by: name, at: Date())
if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
objectWillChange.send()
}
}
@@ -4667,9 +4679,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
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
if let messages = privateChats[peerID.id] {
if let messages = privateChats[peerID] {
if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] }
let existing = Set(privateChats[stableKeyHex]!.map { $0.id })
for msg in messages where !existing.contains(msg.id) {
@@ -4682,17 +4694,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: msg.originalSender,
isPrivate: msg.isPrivate,
recipientNickname: msg.recipientNickname,
senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : PeerID(str: stableKeyHex),
senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex,
mentions: msg.mentions,
deliveryStatus: msg.deliveryStatus
)
privateChats[stableKeyHex]?.append(updated)
}
privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp }
privateChats.removeValue(forKey: peerID.id)
privateChats.removeValue(forKey: peerID)
}
if unreadPrivateMessages.contains(peerID.id) {
unreadPrivateMessages.remove(peerID.id)
if unreadPrivateMessages.contains(peerID) {
unreadPrivateMessages.remove(peerID)
unreadPrivateMessages.insert(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
// Only clear receipts for messages from this specific peer
if let messages = privateChats[peerID.id] {
if let messages = privateChats[peerID] {
for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID {
@@ -4715,8 +4727,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
}
//
}
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
@MainActor
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)
if !staleIDs.isEmpty {
var idsToRemove: [String] = []
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
// 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
if let messages = privateChats[staleID], !messages.isEmpty {
// 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
// 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 {
// Keep this ID - it's a stable key with messages
continue
@@ -5125,7 +5135,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
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
let targetPeerID = actualSenderNoiseKey?.hexEncodedString() ?? ("nostr_" + senderPubkey.prefix(TransportConfig.nostrConvKeyPrefixLength))
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
switch noisePayload.type {
case .privateMessage:
@@ -5133,7 +5143,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
noisePayload,
actualSenderNoiseKey: actualSenderNoiseKey,
senderNickname: senderNickname,
targetPeerID: targetPeerID,
targetPeerID: targetPeerID.id,
messageTimestamp: messageTimestamp,
senderPubkey: senderPubkey
)
@@ -5196,7 +5206,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: PeerID(str: selectedPeer)),
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
@@ -5252,7 +5262,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
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
}
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) {
let targetPeerID = PeerID(str: targetPeerID)
if privateChats[targetPeerID] == nil {
privateChats[targetPeerID] = []
}
@@ -5277,7 +5288,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) {
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
else {
return
@@ -5312,9 +5323,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) {
let targetPeerID = PeerID(str: targetPeerID)
unreadPrivateMessages.remove(targetPeerID)
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)
}
if !sentReadReceipts.contains(message.id) {
@@ -5343,10 +5355,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
messageContent: String
) {
guard shouldMarkAsUnread else { return }
let targetPeerID = PeerID(str: targetPeerID)
unreadPrivateMessages.insert(targetPeerID)
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 {
unreadPrivateMessages.insert(ephemeralPeerID)
}
@@ -5354,7 +5366,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
peerID: targetPeerID.id
)
}
}
@@ -5414,7 +5426,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
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
}
}
@@ -5515,7 +5527,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// For now, create a temporary peer ID based on Nostr pubkey
// 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
let isViewingThisChat = selectedPrivateChatPeer == tempPeerID
@@ -5554,7 +5566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: PeerID(str: tempPeerID),
senderPeerID: tempPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
@@ -5585,7 +5597,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: finalSenderNickname,
message: content,
peerID: tempPeerID
peerID: tempPeerID.id
)
} else {
// Not notifying for old message
@@ -5729,17 +5741,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func migratePrivateChatsIfNeeded(for peerID: String, senderNickname: String) {
let currentFingerprint = getFingerprint(for: peerID)
let peerID = PeerID(str: peerID)
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [String] = []
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]
let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID.id]
// Filter messages to only recent ones
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
@@ -5825,7 +5837,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
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 {
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
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
}
// 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
// This ensures Nostr messages appear in BLE chats
if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: PeerID(str: peerID)) {
let stableKeyHex = peer.noisePublicKey.hexEncodedString()
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 {
@@ -5900,7 +5912,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Handle fingerprint-based chat updates
if let chatFingerprint = selectedPrivateChatFingerprint,
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID],
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID.id],
chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID {
selectedPrivateChatPeer = peerID
}
@@ -5916,7 +5928,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
peerID: peerID.id
)
}
} else {
@@ -5935,25 +5947,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
readerNickname: nickname
)
let recipientID = message.senderPeerID?.id ?? peerID
let recipientID = message.senderPeerID ?? peerID
Task { @MainActor in
var originalTransport: String? = nil
if let noiseKey = Data(hexString: recipientID),
if let noiseKey = Data(hexString: recipientID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.peerNostrPublicKey != nil,
self.meshService.peerNickname(peerID: PeerID(str: recipientID)) == nil {
self.meshService.peerNickname(peerID: recipientID) == nil {
originalTransport = "nostr"
}
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
self.sendReadReceipt(receipt, to: recipientID.id, originalTransport: originalTransport)
}
sentReadReceipts.insert(message.id)
}
// Mark other messages as read
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
if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID],
let messages = viewModel.privateChats[PeerID(str: peerID)],
!messages.isEmpty {
// If the newest private message is from me, always scroll
let lastMsg = messages.last!
@@ -663,7 +663,7 @@ struct ContentView: View {
(["/w"], nil, "see who's online")
]
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)] = [
(["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites")
@@ -747,7 +747,7 @@ struct ContentView: View {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true
var commandDescriptions = [
("/block", String(localized: "content.commands.block", comment: "Description for /block command")),
("/clear", String(localized: "content.commands.clear", comment: "Description for /clear command")),
@@ -955,7 +955,7 @@ struct ContentView: View {
private var privateChatSheetView: some View {
VStack(spacing: 0) {
if let privatePeerID = viewModel.selectedPrivateChatPeer {
if let privatePeerID = viewModel.selectedPrivateChatPeer?.id {
let headerContext = makePrivateHeaderContext(for: privatePeerID)
HStack(spacing: 12) {
@@ -1021,7 +1021,7 @@ struct ContentView: View {
.background(backgroundColor)
}
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
messagesView(privatePeer: viewModel.selectedPrivateChatPeer?.id, isAtBottom: $isAtBottomPrivate)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
+1 -1
View File
@@ -373,7 +373,7 @@ struct VerificationSheetView: View {
}
// 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),
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {