Feature/nostr embedded bitchat (#448)

* UI: prefer mesh radio icon when connected; map short peer ID to full Noise key; rename ephemeral mapping to shortIDToNoiseKey; ensure header flips to purple globe on disconnect and keeps name.

* chore: stop tracking build artifacts; ignore .DerivedData and .Result*

* logging: add global threshold via BITCHAT_LOG_LEVEL and demote chatty logs to debug; keep critical errors/warnings and key state transitions

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-17 11:25:42 +02:00
committed by GitHub
co-authored by jack
parent fb1988ac27
commit 3ebfa85e90
9 changed files with 510 additions and 308 deletions
+313 -247
View File
@@ -151,6 +151,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Missing properties that were removed during refactoring
private var peerIDToPublicKeyFingerprint: [String: String] = [:]
private var selectedPrivateChatFingerprint: String? = nil
// Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity
private var shortIDToNoiseKey: [String: String] = [:]
// Resolve full Noise key for a peer's short ID (used by UI header rendering)
@MainActor
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable
return stable
}
return nil
}
private var peerIndex: [String: BitchatPeer] = [:]
// MARK: - Autocomplete Properties
@@ -848,7 +864,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} else if isMutualFavorite && hasNostrKey,
let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey {
// Mutual favorite offline - send via Nostr
sendViaNostr(content, to: recipientNostrPubkey, messageId: messageID)
sendViaNostr(content, to: recipientNostrPubkey, recipientPeerID: peerID, messageId: messageID)
} else {
// Update delivery status to failed
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -1441,7 +1457,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
// Skip if we already sent an ACK for this message
if !sentReadReceipts.contains(message.id) {
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ")
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
sendNostrAcknowledgment(messageId: message.id, to: nostrPubkey, type: "READ", recipientPeerID: recipPeer)
sentReadReceipts.insert(message.id)
}
}
@@ -2221,9 +2239,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self = self else { return }
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .info)
SecureLogger.log("🔐 Authenticated: \(peerID)", category: SecureLogger.security, level: .debug)
// Update encryption status
if self.verifiedFingerprints.contains(fingerprint) {
self.peerEncryptionStatus[peerID] = .noiseVerified
@@ -2232,10 +2250,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.peerEncryptionStatus[peerID] = .noiseSecured
// Encryption: noiseSecured
}
// Invalidate cache when encryption status changes
self.invalidateEncryptionCache(for: peerID)
// Cache shortID -> full Noise key mapping as soon as session authenticates
if self.shortIDToNoiseKey[peerID] == nil,
let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stable = keyData.hexEncodedString()
self.shortIDToNoiseKey[peerID] = stable
SecureLogger.log("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.prefix(8))",
category: SecureLogger.session, level: .debug)
}
// Schedule UI update
// UI will update automatically
}
@@ -2323,6 +2350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Force UI refresh
objectWillChange.send()
// Cache mapping to full Noise key for session continuity on disconnect
if let peer = unifiedPeerService.getPeer(by: peerID) {
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
shortIDToNoiseKey[peerID] = noiseKeyHex
}
}
// Connection messages removed to reduce chat noise
@@ -2333,6 +2366,47 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Remove ephemeral session from identity manager
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
// If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite)
var derivedStableKeyHex: String? = shortIDToNoiseKey[peerID]
if derivedStableKeyHex == nil,
let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) {
derivedStableKeyHex = key.hexEncodedString()
shortIDToNoiseKey[peerID] = derivedStableKeyHex
}
if let current = selectedPrivateChatPeer, current == peerID,
let stableKeyHex = derivedStableKeyHex {
// Migrate messages view context to stable key so header shows favorite + Nostr globe
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) {
let updated = BitchatMessage(
id: msg.id,
sender: msg.sender,
content: msg.content,
timestamp: msg.timestamp,
isRelay: msg.isRelay,
originalSender: msg.originalSender,
isPrivate: msg.isPrivate,
recipientNickname: msg.recipientNickname,
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)
}
if unreadPrivateMessages.contains(peerID) {
unreadPrivateMessages.remove(peerID)
unreadPrivateMessages.insert(stableKeyHex)
}
selectedPrivateChatPeer = stableKeyHex
objectWillChange.send()
}
// Update peer list immediately and force UI refresh
DispatchQueue.main.async { [weak self] in
@@ -2615,7 +2689,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
@MainActor
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, messageId: String) {
private func sendViaNostr(_ content: String, to recipientNostrPubkey: String, recipientPeerID: String, messageId: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity available", category: SecureLogger.session, level: .error)
return
@@ -2636,11 +2710,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Include sender nickname in message format for better identification
let structuredContent = "MSG:\(messageId):\(nickname):\(content)"
// Build embedded BitChat packet content (bitchat1:...)
guard let embeddedContent = meshService.buildNostrEmbeddedPrivateMessageContent(
content: content,
to: recipientPeerID,
messageID: messageId
) else {
SecureLogger.log("Failed to build embedded BitChat content for Nostr", category: SecureLogger.session, level: .error)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(
content: structuredContent,
content: embeddedContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
) else {
@@ -2653,7 +2734,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .info)
SecureLogger.log("Sent Nostr message via relay", category: SecureLogger.session, level: .debug)
// Update delivery status to sent for Nostr messages
// Need to find which peerID (ephemeral or Noise key) contains this message
@@ -2681,7 +2762,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
@MainActor
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String) {
private func sendNostrAcknowledgment(messageId: String, to recipientNostrPubkey: String, type: String, recipientPeerID: String) {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
SecureLogger.log("No Nostr identity for sending ACK", category: SecureLogger.session, level: .error)
return
@@ -2702,10 +2783,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
}
// Create ACK message format
let ackContent = "ACK:\(type):\(messageId)"
guard let event = try? NostrProtocol.createPrivateMessage(
// Build embedded BitChat ACK content
let ackType: NoisePayloadType? = (type == "DELIVERED") ? .delivered : (type == "READ" ? .readReceipt : nil)
guard let ackTypeUnwrapped = ackType,
let ackContent = meshService.buildNostrEmbeddedAckContent(type: ackTypeUnwrapped, messageID: messageId, to: recipientPeerID),
let event = try? NostrProtocol.createPrivateMessage(
content: ackContent,
recipientPubkey: recipientHexPubkey,
senderIdentity: senderIdentity
@@ -2769,213 +2851,171 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
recipientIdentity: currentIdentity
)
// Log timestamp difference for debugging
// Using rumor timestamp instead of gift wrap timestamp
// Handle favorite notifications
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
handleFavoriteNotification(content: content, from: senderPubkey)
// Expect embedded BitChat packet content
guard content.hasPrefix("bitchat1:") else {
SecureLogger.log("Ignoring non-embedded Nostr DM content", category: SecureLogger.session, level: .debug)
return
}
// Handle acknowledgments
if content.hasPrefix("ACK:") {
handleNostrAcknowledgment(content: content, from: senderPubkey)
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
SecureLogger.log("Failed to decode embedded BitChat packet from Nostr DM", category: SecureLogger.session, level: .error)
return
}
// Handle regular messages
var messageId = UUID().uuidString
var messageContent = content
var extractedNickname: String? = nil
if content.hasPrefix("MSG:") {
let parts = content.split(separator: ":", maxSplits: 3)
if parts.count >= 4 {
// New format with nickname: MSG:ID:NICKNAME:CONTENT
messageId = String(parts[1])
extractedNickname = String(parts[2])
messageContent = String(parts[3])
} else if parts.count >= 3 {
// Old format without nickname: MSG:ID:CONTENT
messageId = String(parts[1])
messageContent = String(parts[2])
// Only process typed noiseEncrypted envelope for private messages/receipts
guard packet.type == MessageType.noiseEncrypted.rawValue else {
SecureLogger.log("Unsupported embedded packet type: \(packet.type)", category: SecureLogger.session, level: .warning)
return
}
// Validate recipient
if let rid = packet.recipientID {
let ridHex = rid.map { String(format: "%02x", $0) }.joined()
if ridHex != meshService.myPeerID {
return
}
}
// Try to find sender's Noise key
// Parse plaintext typed payload
guard let noisePayload = NoisePayload.decode(packet.payload) else {
SecureLogger.log("Failed to parse embedded NoisePayload", category: SecureLogger.session, level: .error)
return
}
// Map sender by Nostr pubkey to Noise key when possible
let senderNoiseKey = findNoiseKey(for: senderPubkey)
// If we can't find the Noise key, try to match by nickname from the message content
// This can happen when receiving messages from someone not yet in favorites
if senderNoiseKey == nil {
SecureLogger.log("⚠️ Cannot find Noise key for Nostr sender: \(senderPubkey.prefix(16))..., will try nickname matching",
category: SecureLogger.session, level: .debug)
// Use extracted nickname if available
handleNostrMessageFromUnknownSender(
messageId: messageId,
content: messageContent,
senderPubkey: senderPubkey,
senderNickname: extractedNickname,
timestamp: Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
)
return
}
// We have the sender's Noise key, proceed normally
guard let actualSenderNoiseKey = senderNoiseKey else { return }
let senderNoiseKeyHex = actualSenderNoiseKey.hexEncodedString()
let senderNickname = FavoritesPersistenceService.shared.getFavoriteStatus(for: actualSenderNoiseKey)?.peerNickname ?? "Unknown"
let actualSenderNoiseKey = senderNoiseKey // may be nil
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// For Nostr messages, always use the stable Noise key hex as the peer ID
// This ensures messages persist across ephemeral peer ID changes
let targetPeerID = senderNoiseKeyHex
// Processing message from sender
// Check if message already exists in local storage
var messageExistsLocally = false
// Check stable key location
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
messageExistsLocally = true
}
// Check all ephemeral peer locations
if !messageExistsLocally {
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) {
messageExistsLocally = true
break
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(16))
switch noisePayload.type {
case .privateMessage:
guard let (messageId, messageContent) = Self.decodePrivateMessageTLV(noisePayload.data) else { return }
// Favorite/unfavorite notifications embedded as private messages
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(messageContent, from: key.hexEncodedString(), senderNickname: senderNickname)
}
return
}
// Check for duplicate
var messageExistsLocally = false
if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
messageExistsLocally = true
}
if !messageExistsLocally {
for (_, messages) in privateChats {
if messages.contains(where: { $0.id == messageId }) { messageExistsLocally = true; break }
}
}
}
// If message already exists locally, skip entirely
if messageExistsLocally {
return // Skipping duplicate message
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Check if message is recent (for notification purposes)
let messageAgeSeconds = Date().timeIntervalSince(messageTimestamp)
let isRecentMessage = messageAgeSeconds < 30 // Message must be less than 30 seconds old
// Check if we're viewing this chat BEFORE adding the message
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
selectedPeerData.noisePublicKey == actualSenderNoiseKey {
// We're viewing this chat via ephemeral ID
isViewingThisChat = true
}
// Determine if this should be marked as unread BEFORE adding to chats
// Only mark as unread if: not previously read AND not currently viewing
// During startup phase, only block OLD messages (>30s) from being marked as unread
// Recent messages should always be marked as unread if not previously read
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
// Handle based on read status
// Create message
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname, // We are the recipient
senderPeerID: targetPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
// Add to private chat (not public messages!)
if privateChats[targetPeerID] == nil {
privateChats[targetPeerID] = []
}
privateChats[targetPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
// IMPORTANT: Also add to ephemeral peer chat if one is open
// Find any ephemeral peer ID that maps to this stable Noise key
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id, ephemeralPeerID != targetPeerID {
// Also add the message to the ephemeral peer's chat
if privateChats[ephemeralPeerID] == nil {
privateChats[ephemeralPeerID] = []
if messageExistsLocally { return }
let wasReadBefore = sentReadReceipts.contains(messageId)
// Is viewing?
var isViewingThisChat = false
if selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = selectedPrivateChatPeer,
let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
isViewingThisChat = true
}
// Check if message doesn't already exist to avoid duplicates
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
}
// Send delivery acknowledgment via Nostr only if we haven't read it before
// If we already read it, we've already sent DELIVERED (and probably READ) ACKs
if !wasReadBefore {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
}
// Handle notifications and read receipts
if wasReadBefore {
// Message was read in a previous session - don't mark as unread or notify
// Just add to chat history silently
// Not marking previously-read message as unread
} else if isViewingThisChat {
// We're viewing this chat - mark as read and send read receipt
unreadPrivateMessages.remove(targetPeerID)
// Also remove ephemeral ID from unread if present
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
// Send read acknowledgment
if !sentReadReceipts.contains(messageId) {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
sentReadReceipts.insert(messageId)
}
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
if shouldMarkAsUnread {
unreadPrivateMessages.insert(targetPeerID)
// Also mark ephemeral peer ID as unread if present
if let ephemeralPeerID = unifiedPeerService.peers.first(where: { peer in
peer.noisePublicKey == actualSenderNoiseKey
})?.id, ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
// Only notify if it's a recent message
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
} else {
// Not notifying for old message
// Recency check
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && (isRecentMessage || !isStartupPhase)
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: nickname,
senderPeerID: targetPeerID,
mentions: nil,
deliveryStatus: .delivered(to: nickname, at: Date())
)
if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] }
privateChats[targetPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
// Mirror to ephemeral if applicable
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] }
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
privateChats[ephemeralPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
}
}
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
if !wasReadBefore, let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED", recipientPeerID: key.hexEncodedString())
}
if wasReadBefore {
// do nothing
} else if isViewingThisChat {
unreadPrivateMessages.remove(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ", recipientPeerID: key.hexEncodedString())
sentReadReceipts.insert(messageId)
}
} else {
if shouldMarkAsUnread {
unreadPrivateMessages.insert(targetPeerID)
if let key = actualSenderNoiseKey,
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
}
}
}
objectWillChange.send()
case .delivered:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
// Update status to delivered
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .delivered(to: peerName, at: Date())
objectWillChange.send()
}
case .readReceipt:
guard let messageID = String(data: noisePayload.data, encoding: .utf8) else { return }
let peerName = senderNickname
if let messages = privateChats[targetPeerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[targetPeerID]?[idx].deliveryStatus = .read(by: peerName, at: Date())
objectWillChange.send()
}
}
objectWillChange.send()
} catch {
SecureLogger.log("Failed to decrypt Nostr message: \(error)", category: SecureLogger.session, level: .error)
}
@@ -3050,6 +3090,37 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .warning)
}
}
// MARK: - Base64URL utils
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
let rem = str.count % 4
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str)
}
// Decode PrivateMessagePacket TLV (local minimal decoder)
private static func decodePrivateMessageTLV(_ data: Data) -> (String, String)? {
var offset = 0
var messageID: String?
var content: String?
while offset + 2 <= data.count {
let type = data[offset]; offset += 1
let length = Int(data[offset]); offset += 1
guard offset + length <= data.count else { return nil }
let value = data[offset..<offset+length]
offset += length
if type == 0x00 {
messageID = String(data: value, encoding: .utf8)
} else if type == 0x01 {
content = String(data: value, encoding: .utf8)
}
}
if let id = messageID, let c = content { return (id, c) }
return nil
}
@MainActor
private func handleFavoriteNotificationFromMesh(_ content: String, from peerID: String, senderNickname: String) {
@@ -3178,10 +3249,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
privateChats[tempPeerID]?.append(message)
trimPrivateChatMessagesIfNeeded(for: tempPeerID)
// Send delivery acknowledgment only if we haven't read it before
if !wasReadBefore {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "DELIVERED")
}
// For unknown senders (no Noise key), skip sending Nostr ACKs
// Handle based on read status
if wasReadBefore {
@@ -3189,10 +3257,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Not marking previously-read message as unread
} else if isViewingThisChat {
// Viewing this chat - mark as read
if !sentReadReceipts.contains(messageId) {
sendNostrAcknowledgment(messageId: messageId, to: senderPubkey, type: "READ")
sentReadReceipts.insert(messageId)
}
// No read ACKs for unknown senders
} else {
// Not viewing and not previously read
// Use pre-calculated shouldMarkAsUnread to avoid UI flicker
@@ -3272,17 +3337,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
if let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = noisePublicKey.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else { return }
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("Sent favorite notification via Nostr", category: SecureLogger.session, level: .debug)
}
}
}
@@ -3305,7 +3370,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Try mesh first for connected peers
if meshService.isPeerConnected(peerID) {
meshService.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .info)
SecureLogger.log("📤 Sent favorite notification via BLE to \(peerID)", category: SecureLogger.session, level: .debug)
} else if let key = noiseKey,
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: key),
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
@@ -3315,21 +3380,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
if let event = try? NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) {
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .info)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
} else {
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
let recipientPeerID = key.hexEncodedString()
guard let embedded = meshService.buildNostrEmbeddedPrivateMessageContent(content: content, to: recipientPeerID, messageID: UUID().uuidString),
let event = try? NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipientNostrPubkey,
senderIdentity: senderIdentity
) else {
SecureLogger.log("❌ Failed to create Nostr message for favorite notification", category: SecureLogger.session, level: .error)
return
}
if let relayManager = nostrRelayManager {
relayManager.sendEvent(event)
SecureLogger.log("📤 Sent favorite notification via Nostr to \(favoriteStatus.peerNickname)", category: SecureLogger.session, level: .debug)
} else {
SecureLogger.log("❌ NostrRelayManager is nil - cannot send favorite notification", category: SecureLogger.session, level: .error)
}
} else {
SecureLogger.log("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: SecureLogger.session, level: .warning)
@@ -3471,7 +3537,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
/// Handle incoming private message
@MainActor
private func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .info)
SecureLogger.log("📥 handlePrivateMessage called for message from \(message.sender)", category: SecureLogger.session, level: .debug)
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
@@ -3685,5 +3751,5 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
#endif
}
} // End of ChatViewModel class
}
// End of ChatViewModel class