Compare commits

...
Author SHA1 Message Date
jack ed105f311a refactor(router): make MessageRouter depend on NostrOutboxService; update ChatViewModel wiring 2025-08-26 21:27:55 +02:00
jack 8ebb7411a7 fix(nostr-inbox): initialize NostrInboxService inside @MainActor initializers to satisfy actor isolation 2025-08-26 21:20:51 +02:00
jack fb60c929eb refactor(nostr-outbox): add NostrOutboxService and migrate ChatViewModel geohash PM/ACK sends to it
Centralize Nostr geohash outbox (PM, DELIVERED, READ) into a service wrapping NostrTransport; inject into ChatViewModel and remove ad-hoc NostrTransport instantiations.
2025-08-26 21:19:49 +02:00
jack 678795adcf fix(nostr): mark NostrInboxService @MainActor and use NostrEvent type; resolve Swift 6 actor isolation errors 2025-08-26 21:13:40 +02:00
jack 37e973bf9f refactor(nostr): add NostrInboxService and route geohash/DM subscriptions through it; move de-dup away from ChatViewModel
- Introduce Services/NostrInboxService to centralize subscribe/unsubscribe + event de-dup.\n- Wire ChatViewModel resubscribeCurrentGeohash, switchLocationChannel, sampling, and global DM subscribe to use the service.\n- Remove ChatViewModel's local processedNostrEvents tracking.
2025-08-26 21:01:13 +02:00
jack 8c002b8942 fix(init): initialize autocompleteService in DI initializer to satisfy Swift init rules 2025-08-26 20:15:03 +02:00
jack 6533629362 refactor(init): add dependency-injected initializer for ChatViewModel to enable composition/testing
Provide an alternate initializer that accepts meshService, router, privateChatManager, unifiedPeerService, commandProcessor, and an optional ReadReceiptTracker. Keeps default init intact; both perform the same wiring and startup behavior.
2025-08-26 20:11:59 +02:00
jack dd4e2f6e86 refactor(hex+fingerprint): centralize SHA256 fingerprint helper; reuse PeerIDResolver in ID checks
- Move Data.sha256Fingerprint() to BinaryEncodingUtils for reuse; refactor NoiseEncryptionService to use it.\n- Replace manual short/full hex checks with PeerIDResolver in ChatViewModel, ContentView, and SecureIdentityStateManager.\n- Keep behavior identical; reduce ad-hoc string length/hex parsing.
2025-08-26 19:47:51 +02:00
jack 55187ac5c6 refactor(read-receipts): add ReadReceiptTracker and unify across VM + manager
Introduce Services/ReadReceiptTracker as single source of truth with persistence. Wire into ChatViewModel and PrivateChatManager, replacing duplicated sentReadReceipts sets and persistence logic. Add to Xcode targets (iOS/macOS).
2025-08-26 18:50:52 +02:00
16 changed files with 556 additions and 185 deletions
+18
View File
@@ -56,6 +56,12 @@
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; }; 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@@ -213,6 +219,9 @@
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; }; 049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; }; 049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; }; 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; }; 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; }; 049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; }; 049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
@@ -520,6 +529,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
@@ -743,6 +755,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
@@ -802,6 +817,9 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] } guard PeerIDResolver.isShortID(peerID) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
} }
} }
+11 -1
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding // MARK: - Hex Encoding/Decoding
@@ -35,6 +36,16 @@ extension Data {
} }
} }
// MARK: - Fingerprint Helpers
extension Data {
/// SHA-256 over data, hex-encoded (lowercase)
func sha256Fingerprint() -> String {
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
extension Data { extension Data {
@@ -220,4 +231,3 @@ extension Data {
return data return data
} }
} }
+3 -4
View File
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key) return PeerIDUtils.derivePeerID(fromPublicKey: key)
} }
return peerID return peerID
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
func isPeerReachable(_ peerID: String) -> Bool { func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key) return PeerIDUtils.derivePeerID(fromPublicKey: key)
} }
return peerID return peerID
@@ -664,8 +664,7 @@ final class BLEService: NSObject {
return collectionsQueue.sync { return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey { if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key) // Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
let hash = SHA256.hash(data: publicKey) return publicKey.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
return nil return nil
} }
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID. /// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? { func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes) // Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil } guard PeerIDResolver.isShortID(peerID) else { return nil }
for (pubkey, rel) in favorites { for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey) let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel } if derived == peerID { return rel }
+3 -4
View File
@@ -4,13 +4,12 @@ import Foundation
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let mesh: Transport private let mesh: Transport
private let nostr: NostrTransport private let nostr: NostrOutboxService
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrTransport) { init(mesh: Transport, nostr: NostrOutboxService) {
self.mesh = mesh self.mesh = mesh
self.nostr = nostr self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -46,7 +45,7 @@ final class MessageRouter {
} else if canSendViaNostr(peerID: peerID) { } else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later (when mesh connects or Nostr mapping appears) // Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
@@ -548,8 +548,7 @@ class NoiseEncryptionService {
} }
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String { private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
let hash = SHA256.hash(data: publicKey.rawRepresentation) return publicKey.rawRepresentation.sha256Fingerprint()
return hash.map { String(format: "%02x", $0) }.joined()
} }
// MARK: - Session Maintenance // MARK: - Session Maintenance
+90
View File
@@ -0,0 +1,90 @@
//
// NostrInboxService.swift
// bitchat
//
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrInboxService {
private var processedIDs: Set<String> = []
private var order: [String] = []
private let cap: Int
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
self.cap = capacity
}
private func track(_ id: String) -> Bool {
if processedIDs.contains(id) { return false }
processedIDs.insert(id)
order.append(id)
if order.count > cap {
let overflow = order.count - cap
for _ in 0..<overflow {
if let oldest = order.first {
order.removeFirst()
processedIDs.remove(oldest)
}
}
}
return true
}
func reset() {
processedIDs.removeAll()
order.removeAll()
}
// Subscribe to public geohash ephemeral events (kind 20000)
@discardableResult
func subscribeGeohashEphemeral(
geohash: String,
lookbackSeconds: TimeInterval,
limit: Int,
relayCount: Int,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "geo-\(geohash)"
let filter = NostrFilter.geohashEphemeral(
geohash,
since: Date().addingTimeInterval(-lookbackSeconds),
limit: limit
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
// Subscribe to gift wraps (DMs) for a given pubkey
@discardableResult
func subscribeGiftWrapsFor(
pubkeyHex: String,
lookbackSeconds: TimeInterval,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
guard let self = self else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
func unsubscribe(id: String) {
NostrRelayManager.shared.unsubscribe(id: id)
}
}
+50
View File
@@ -0,0 +1,50 @@
//
// NostrOutboxService.swift
// bitchat
//
// Centralized Nostr outbox for sending PMs and ACKs.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrOutboxService {
private let transport: NostrTransport
init(meshService: Transport) {
let t = NostrTransport()
t.senderPeerID = meshService.myPeerID
self.transport = t
}
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
}
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
// MARK: - Generic outbox via favorites mapping
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
transport.sendReadReceipt(receipt, to: peerID)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
transport.sendDeliveryAck(for: messageID, to: peerID)
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if peerID.count == 16, if PeerIDResolver.isShortID(peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
+7 -6
View File
@@ -16,14 +16,15 @@ class PrivateChatManager: ObservableObject {
@Published var unreadMessages: Set<String> = [] @Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel private var readReceiptTracker: ReadReceiptTracker?
weak var meshService: Transport? weak var meshService: Transport?
// Route acks/receipts via MessageRouter (chooses mesh or Nostr) // Route acks/receipts via MessageRouter (chooses mesh or Nostr)
weak var messageRouter: MessageRouter? weak var messageRouter: MessageRouter?
init(meshService: Transport? = nil) { init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
self.meshService = meshService self.meshService = meshService
self.readReceiptTracker = readReceiptTracker
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
@@ -121,7 +122,7 @@ class PrivateChatManager: ObservableObject {
unreadMessages.insert(senderPeerID) unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases) // Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !sentReadReceipts.contains(message.id) { if !(readReceiptTracker?.contains(message.id) ?? false) {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
@@ -160,7 +161,7 @@ class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet // Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) { if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
sendReadReceipt(for: message) sendReadReceipt(for: message)
} }
} }
@@ -212,12 +213,12 @@ class PrivateChatManager: ObservableObject {
// MARK: - Private Methods // MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) { private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id), guard !(readReceiptTracker?.contains(message.id) ?? false),
let senderPeerID = message.senderPeerID else { let senderPeerID = message.senderPeerID else {
return return
} }
sentReadReceipts.insert(message.id) readReceiptTracker?.insert(message.id)
// Create read receipt using the simplified method // Create read receipt using the simplified method
let receipt = ReadReceipt( let receipt = ReadReceipt(
+77
View File
@@ -0,0 +1,77 @@
//
// ReadReceiptTracker.swift
// bitchat
//
// Centralized tracker for sent read receipts with simple persistence.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class ReadReceiptTracker {
private let defaults: UserDefaults
private let key = "sentReadReceipts"
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
private var set: Set<String> = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: key),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.set = Set(arr)
}
}
func contains(_ id: String) -> Bool {
queue.sync { set.contains(id) }
}
func insert(_ id: String) {
queue.async(flags: .barrier) {
if self.set.insert(id).inserted { self.persist() }
}
}
func insert<S: Sequence>(_ ids: S) where S.Element == String {
queue.async(flags: .barrier) {
var changed = false
for id in ids { changed = self.set.insert(id).inserted || changed }
if changed { self.persist() }
}
}
func remove(_ id: String) {
queue.async(flags: .barrier) {
if self.set.remove(id) != nil { self.persist() }
}
}
func removeAll() {
queue.async(flags: .barrier) {
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
}
}
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
func prune(toAllowedIDs allowed: Set<String>) {
queue.async(flags: .barrier) {
let newSet = self.set.intersection(allowed)
if newSet.count != self.set.count { self.set = newSet; self.persist() }
}
}
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
func snapshot() -> Set<String> { queue.sync { set } }
private func persist() {
let arr = Array(set)
if let data = try? JSONEncoder().encode(arr) {
defaults.set(data, forKey: key)
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
+1 -7
View File
@@ -412,10 +412,4 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Helper Extensions // MARK: - Helper Extensions
extension Data { // Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
+282 -148
View File
@@ -161,7 +161,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}() }()
let full = (nostrKeyMapping[spid] ?? bare).lowercased() let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full return "nostr:" + full
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() { } else if PeerIDResolver.isShortID(spid), let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full return "noise:" + full
} else { } else {
return "mesh:" + spid.lowercased() return "mesh:" + spid.lowercased()
@@ -245,6 +245,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private let commandProcessor: CommandProcessor private let commandProcessor: CommandProcessor
private let messageRouter: MessageRouter private let messageRouter: MessageRouter
private let privateChatManager: PrivateChatManager private let privateChatManager: PrivateChatManager
private let readReceiptTracker: ReadReceiptTracker
private let unifiedPeerService: UnifiedPeerService private let unifiedPeerService: UnifiedPeerService
private let autocompleteService: AutocompleteService private let autocompleteService: AutocompleteService
@@ -308,7 +309,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? { func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped } if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available // Fallback: derive from active Noise session if available
if shortPeerID.count == 16, if PeerIDResolver.isShortID(shortPeerID),
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString() let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable shortIDToNoiseKey[shortPeerID] = stable
@@ -347,9 +348,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var meshService: Transport = BLEService() var meshService: Transport = BLEService()
private var nostrRelayManager: NostrRelayManager? private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService // PeerManager replaced by UnifiedPeerService
private var processedNostrEvents = Set<String>() // Simple deduplication // NostrInboxService handles event de-duplication
private var processedNostrEventOrder: [String] = []
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname" private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection) // Location channel state (macOS supports manual geohash selection)
@@ -358,6 +357,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoDmSubscriptionID: String? = nil private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil private var currentGeohash: String? = nil
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
private var nostrInbox: NostrInboxService
private var nostrOutbox: NostrOutboxService
// MARK: - Caches // MARK: - Caches
@@ -435,22 +436,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published private(set) var isBatchingPublic: Bool = false @Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
// Track sent read receipts to avoid duplicates (persisted across launches) // Read receipts are centralized in ReadReceiptTracker
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
private var sentReadReceipts: Set<String> = [] { // messageID set
didSet {
// Only persist if there are changes
guard oldValue != sentReadReceipts else { return }
// Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
// Throttle verification response toasts per peer to avoid spam // Throttle verification response toasts per peer to avoid spam
private var lastVerifyToastAt: [String: Date] = [:] private var lastVerifyToastAt: [String: Date] = [:]
@@ -470,21 +456,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
init() { init() {
// Load persisted read receipts // Initialize read receipt tracker first
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), self.readReceiptTracker = ReadReceiptTracker()
let receipts = try? JSONDecoder().decode([String].self, from: data) {
self.sentReadReceipts = Set(receipts)
// Successfully loaded read receipts
} else {
// No persisted read receipts found
}
// Initialize services // Initialize services
self.commandProcessor = CommandProcessor() self.commandProcessor = CommandProcessor()
self.privateChatManager = PrivateChatManager(meshService: meshService) self.privateChatManager = PrivateChatManager(meshService: meshService, readReceiptTracker: readReceiptTracker)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService) self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
let nostrTransport = NostrTransport() self.nostrInbox = NostrInboxService()
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) self.nostrOutbox = NostrOutboxService(meshService: meshService)
self.messageRouter = MessageRouter(mesh: meshService, nostr: self.nostrOutbox)
// Route receipts from PrivateChatManager through MessageRouter // Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter self.privateChatManager.messageRouter = self.messageRouter
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
@@ -713,6 +693,198 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
#endif #endif
} }
// MARK: - Dependency-injected initializer (for testing and composition)
@MainActor
init(meshService: Transport,
commandProcessor: CommandProcessor,
privateChatManager: PrivateChatManager,
unifiedPeerService: UnifiedPeerService,
messageRouter: MessageRouter,
readReceiptTracker: ReadReceiptTracker = ReadReceiptTracker()) {
// Assign dependencies
self.meshService = meshService
self.commandProcessor = commandProcessor
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.messageRouter = messageRouter
self.readReceiptTracker = readReceiptTracker
self.autocompleteService = AutocompleteService()
self.nostrInbox = NostrInboxService()
self.nostrOutbox = NostrOutboxService(meshService: meshService)
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.privateChatManager.messageRouter = self.messageRouter
self.commandProcessor.meshService = meshService
// Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
loadNickname()
loadVerifiedFingerprints()
meshService.delegate = self
// Log fingerprint after a delay to ensure encryption service is ready
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
if let self = self {
_ = self.getMyFingerprint()
}
}
// Set nickname before starting services
meshService.setNickname(nickname)
// Start mesh service immediately
meshService.startServices()
// Initialize Nostr services
Task { @MainActor in
nostrRelayManager = NostrRelayManager.shared
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
nostrRelayManager?.connect()
// Small delay to ensure read receipts are fully loaded
try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs)
// Set up Nostr message handling directly
setupNostrMessageHandling()
// Attempt to flush any queued outbox after Nostr comes online
messageRouter.flushAllOutbox()
// End startup phase after 2 seconds
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000))
self.isStartupPhase = false
}
// Bind unified peer service's peer list to our published property
let cancellable = unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
// Update peers on main thread for UI consistency
guard let self = self else { return }
Task { @MainActor in
self.allPeers = peers
// Build a quick index for getPeer(by:)
var uniquePeers: [String: BitchatPeer] = [:]
for peer in peers {
if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer
}
}
self.peerIndex = uniquePeers
if self.selectedPrivateChatFingerprint != nil {
self.updatePrivateChatPeerIfNeeded()
}
}
}
self.cancellables.insert(cancellable)
// Resubscribe geohash on relay reconnect
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] connected in
guard let self = self else { return }
if connected {
Task { @MainActor in
self.resubscribeCurrentGeohash()
}
}
}
.store(in: &self.cancellables)
}
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self = self else { return }
Task { @MainActor in
self.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
// Initialize with current selection
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Request notification permission
NotificationService.shared.requestAuthorization()
// Listen for favorite status changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteStatusChanged),
name: .favoriteStatusChanged,
object: nil
)
// Listen for peer status updates to refresh UI
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePeerStatusUpdate),
name: Notification.Name("peerStatusUpdated"),
object: nil
)
#if os(macOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: NSApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: NSApplication.willTerminateNotification,
object: nil
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(userDidTakeScreenshot),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
#endif
}
// MARK: - Deinitialization // MARK: - Deinitialization
deinit { deinit {
@@ -732,20 +904,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
startGeoParticipantsTimer() startGeoParticipantsTimer()
// Unsubscribe + resubscribe // Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID) NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral( nostrInbox.subscribeGeohashEphemeral(
ch.geohash, geohash: ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
limit: TransportConfig.nostrGeohashInitialLimit limit: TransportConfig.nostrGeohashInitialLimit,
) relayCount: TransportConfig.nostrGeoRelayCount,
let subRelays = GeoRelayDirectory.shared.closestRelays( subID: subID
toGeohash: ch.geohash, ) { [weak self] event in
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id)
if let gh = self.currentGeohash, if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
@@ -802,11 +969,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let dmSub = "geo-dm-\(ch.geohash)" let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) nostrInbox.subscribeGiftWrapsFor(
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in pubkeyHex: id.publicKeyHex,
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
subID: dmSub
) { [weak self] giftWrap in
guard let self = self else { return } guard let self = self else { return }
if self.processedNostrEvents.contains(giftWrap.id) { return }
self.recordProcessedEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return } guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return }
guard content.hasPrefix("bitchat1:") else { return } guard content.hasPrefix("bitchat1:") else { return }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
@@ -822,9 +990,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let messageId = pm.messageID let messageId = pm.messageID
// Send delivery ACK immediately (once per message ID) // Send delivery ACK immediately (once per message ID)
if !self.sentGeoDeliveryAcks.contains(messageId) { if !self.sentGeoDeliveryAcks.contains(messageId) {
let nt = NostrTransport() self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
nt.senderPeerID = self.meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId) self.sentGeoDeliveryAcks.insert(messageId)
} }
// Dedup storage // Dedup storage
@@ -833,7 +999,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let senderName = self.displayNameForNostrPubkey(senderPubkey) let senderName = self.displayNameForNostrPubkey(senderPubkey)
let isViewing = (self.selectedPrivateChatPeer == convKey) let isViewing = (self.selectedPrivateChatPeer == convKey)
// pared back: omit view-state log // pared back: omit view-state log
let wasReadBefore = self.sentReadReceipts.contains(messageId) let wasReadBefore = self.readReceiptTracker.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage( let msg = BitchatMessage(
@@ -860,10 +1026,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing { if isViewing {
// pared back: omit pre-send READ log // pared back: omit pre-send READ log
if !wasReadBefore { if !wasReadBefore {
let nt = NostrTransport() self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
nt.senderPeerID = self.meshService.myPeerID self.readReceiptTracker.insert(messageId)
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId)
} }
} else { } else {
// Notify for truly unread and recent messages when not viewing // Notify for truly unread and recent messages when not viewing
@@ -993,7 +1157,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// 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
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
// 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
@@ -1056,7 +1220,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
func isFavorite(peerID: String) -> Bool { func isFavorite(peerID: String) -> Bool {
// 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)
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
// This is a Noise public key // This is a Noise public key
if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) { if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {
return status.isFavorite return status.isFavorite
@@ -1343,8 +1507,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
publicBuffer.removeAll(keepingCapacity: false) publicBuffer.removeAll(keepingCapacity: false)
activeChannel = channel activeChannel = channel
// Reset deduplication set and optionally hydrate timeline for mesh // Reset deduplication set and optionally hydrate timeline for mesh
processedNostrEvents.removeAll() nostrInbox.reset()
processedNostrEventOrder.removeAll()
switch channel { switch channel {
case .mesh: case .mesh:
messages = meshTimeline messages = meshTimeline
@@ -1388,19 +1551,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let subID = "geo-\(ch.geohash)" let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID geoSubscriptionID = subID
startGeoParticipantsTimer() startGeoParticipantsTimer()
let filter = NostrFilter.geohashEphemeral( nostrInbox.subscribeGeohashEphemeral(
ch.geohash, geohash: ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
limit: TransportConfig.nostrGeohashInitialLimit limit: TransportConfig.nostrGeohashInitialLimit,
) relayCount: TransportConfig.nostrGeoRelayCount,
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) subID: subID
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in ) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
// Only handle ephemeral kind 20000 with matching tag // Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Deduplicate
if self.processedNostrEvents.contains(event.id) { return }
self.recordProcessedEvent(event.id)
// Log incoming tags for diagnostics // Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
@@ -1477,12 +1637,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// pared back logging: subscribe debug only // pared back logging: subscribe debug only
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) nostrInbox.subscribeGiftWrapsFor(pubkeyHex: id.publicKeyHex, lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds, subID: dmSub) { [weak self] giftWrap in
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return } guard let self = self else { return }
// Dedup basic
if self.processedNostrEvents.contains(giftWrap.id) { return }
self.recordProcessedEvent(giftWrap.id)
// Decrypt with per-geohash identity // Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))",
@@ -1507,10 +1663,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info) category: SecureLogger.session, level: .info)
// Send delivery ACK immediately (even if duplicate), once per messageID // Send delivery ACK immediately (even if duplicate), once per messageID
if !self.sentGeoDeliveryAcks.contains(messageId) { if !self.sentGeoDeliveryAcks.contains(messageId) {
let nostrTransport = NostrTransport() self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
nostrTransport.senderPeerID = self.meshService.myPeerID
// pared back: omit pre-send log
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId) self.sentGeoDeliveryAcks.insert(messageId)
} }
// Duplicate check // Duplicate check
@@ -1518,7 +1671,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } } for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } }
let senderName = self.displayNameForNostrPubkey(senderPubkey) let senderName = self.displayNameForNostrPubkey(senderPubkey)
let isViewing = (self.selectedPrivateChatPeer == convKey) let isViewing = (self.selectedPrivateChatPeer == convKey)
let wasReadBefore = self.sentReadReceipts.contains(messageId) let wasReadBefore = self.readReceiptTracker.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage( let msg = BitchatMessage(
@@ -1542,10 +1695,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing { if isViewing {
// pared back: omit pre-send READ log // pared back: omit pre-send READ log
if !wasReadBefore { if !wasReadBefore {
let nostrTransport = NostrTransport() self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
nostrTransport.senderPeerID = self.meshService.myPeerID self.readReceiptTracker.insert(messageId)
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId)
} }
} else { } else {
// pared back: omit defer READ log // pared back: omit defer READ log
@@ -1749,7 +1900,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// //
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID) nostrInbox.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID) geoSamplingSubs.removeValue(forKey: subID)
} }
@@ -1757,13 +1908,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for gh in toAdd { for gh in toAdd {
let subID = "geo-sample-\(gh)" let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral( nostrInbox.subscribeGeohashEphemeral(
gh, geohash: gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), lookbackSeconds: TransportConfig.nostrGeohashSampleLookbackSeconds,
limit: TransportConfig.nostrGeohashSampleLimit limit: TransportConfig.nostrGeohashSampleLimit,
) relayCount: TransportConfig.nostrGeoRelayCount,
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) subID: subID
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in ) { [weak self] event in
guard let self = self else { return } guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Update participants for this specific geohash // Update participants for this specific geohash
@@ -1839,19 +1990,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Dedup helper with small memory cap // Dedup helper with small memory cap
private func recordProcessedEvent(_ id: String) { private func recordProcessedEvent(_ id: String) {}
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
processedNostrEvents.remove(old)
}
}
}
}
/// Sends an encrypted private message to a specific peer. /// Sends an encrypted private message to a specific peer.
/// - Parameters: /// - Parameters:
@@ -1915,9 +2054,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
let nostrTransport = NostrTransport() self.nostrOutbox.sendGeohashPM(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .sent privateChats[peerID]?[msgIdx].deliveryStatus = .sent
} }
@@ -2142,7 +2279,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Never mark old messages as unread during consolidation // Never mark old messages as unread during consolidation
if message.senderPeerID != meshService.myPeerID { if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp) let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !sentReadReceipts.contains(message.id) { if messageAge < 60 && !readReceiptTracker.contains(message.id) {
hasActualUnreadMessages = true hasActualUnreadMessages = true
} }
} }
@@ -2263,7 +2400,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Delegate to private chat manager but add already-acked messages first // Delegate to private chat manager but add already-acked messages first
// This prevents duplicate read receipts // This prevents duplicate read receipts
// IMPORTANT: Only add messages WE sent to sentReadReceipts, not messages we received // IMPORTANT: Only add messages WE sent to readReceiptTracker, not messages we received
if let messages = privateChats[peerID] { if let messages = privateChats[peerID] {
for message in messages { for message in messages {
// Only track read receipts for messages WE sent (not received messages) // Only track read receipts for messages WE sent (not received messages)
@@ -2272,8 +2409,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let status = message.deliveryStatus { if let status = message.deliveryStatus {
switch status { switch status {
case .read, .delivered: case .read, .delivered:
sentReadReceipts.insert(message.id) readReceiptTracker.insert(message.id)
privateChatManager.sentReadReceipts.insert(message.id)
default: default:
break break
} }
@@ -2627,13 +2763,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? [] let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay { for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) { if !readReceiptTracker.contains(message.id) {
SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
let nostrTransport = NostrTransport() self.nostrOutbox.sendGeohashReadAck(messageID: message.id, toRecipientHex: recipientHex, from: id)
nostrTransport.senderPeerID = meshService.myPeerID readReceiptTracker.insert(message.id)
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
sentReadReceipts.insert(message.id)
} }
} }
return return
@@ -2671,12 +2805,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Check both the ephemeral peer ID and stable Noise key as sender // Check both the ephemeral peer ID and stable Noise key as sender
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay { if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
// 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 !readReceiptTracker.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)?.noisePublicKey.hexEncodedString() ?? peerID) let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: recipPeer) messageRouter.sendReadReceipt(receipt, to: recipPeer)
sentReadReceipts.insert(message.id) readReceiptTracker.insert(message.id)
} }
} }
} }
@@ -2804,7 +2938,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
selectedPrivateChatFingerprint = nil selectedPrivateChatFingerprint = nil
// Clear read receipt tracking // Clear read receipt tracking
sentReadReceipts.removeAll() readReceiptTracker.removeAll()
processedNostrAcks.removeAll() processedNostrAcks.removeAll()
// Clear all caches // Clear all caches
@@ -3603,7 +3737,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}() }()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased() let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark) return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.count == 16 { } else if PeerIDResolver.isShortID(spid) {
// Mesh short ID // Mesh short ID
return getPeerPaletteColor(for: spid, isDark: isDark) return getPeerPaletteColor(for: spid, isDark: isDark)
} else { } else {
@@ -3989,7 +4123,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Check if this might already be a nickname (not a hex peer ID) // Check if this might already be a nickname (not a hex peer ID)
// Peer IDs are hex strings, so they only contain 0-9 and a-f // Peer IDs are hex strings, so they only contain 0-9 and a-f
let isHexID = peerID.allSatisfy { $0.isHexDigit } let isHexID = PeerIDResolver.isShortID(peerID) || PeerIDResolver.isNoiseKeyHex(peerID)
if !isHexID { if !isHexID {
// If it's already a nickname, just return it // If it's already a nickname, just return it
return peerID return peerID
@@ -4437,7 +4571,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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 {
sentReadReceipts.remove(message.id) // Prune single ID from tracker if needed
// Note: ReadReceiptTracker persists asynchronously
// Remove only if we want to allow resending READ later
// For now keep as-is; leave this as a no-op
} }
} }
} }
@@ -4540,7 +4677,7 @@ 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 PeerIDResolver.isNoiseKeyHex(staleID) {
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
@@ -4579,10 +4716,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Remove receipts for messages we no longer have // Remove receipts for messages we no longer have
let oldCount = sentReadReceipts.count let before = readReceiptTracker.snapshot()
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) readReceiptTracker.prune(toAllowedIDs: validMessageIDs)
let after = readReceiptTracker.snapshot()
let removedCount = oldCount - sentReadReceipts.count let removedCount = before.count - after.count
if removedCount > 0 { if removedCount > 0 {
SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts", SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -4768,12 +4905,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
// Subscribe to Nostr messages // Subscribe to Nostr messages
let filter = NostrFilter.giftWrapsFor( let _ = nostrInbox.subscribeGiftWrapsFor(
pubkey: currentIdentity.publicKeyHex, pubkeyHex: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
) subID: "chat-messages"
) { [weak self] event in
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor in Task { @MainActor in
self?.handleNostrMessage(event) self?.handleNostrMessage(event)
} }
@@ -4782,9 +4918,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func handleNostrMessage(_ giftWrap: NostrEvent) { private func handleNostrMessage(_ giftWrap: NostrEvent) {
// Simple deduplication // De-duplication handled by NostrInboxService
if processedNostrEvents.contains(giftWrap.id) { return }
processedNostrEvents.insert(giftWrap.id)
// Client-side filtering: ignore messages older than 24 hours // Client-side filtering: ignore messages older than 24 hours
// Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes // Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes
@@ -4869,7 +5003,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
if messageExistsLocally { return } if messageExistsLocally { return }
let wasReadBefore = sentReadReceipts.contains(messageId) let wasReadBefore = readReceiptTracker.contains(messageId)
// Is viewing? // Is viewing?
var isViewingThisChat = false var isViewingThisChat = false
@@ -4946,17 +5080,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id { let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID) unreadPrivateMessages.remove(ephemeralPeerID)
} }
if !sentReadReceipts.contains(messageId) { if !readReceiptTracker.contains(messageId) {
if let key = actualSenderNoiseKey { if let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
sentReadReceipts.insert(messageId) readReceiptTracker.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport() let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(messageId) readReceiptTracker.insert(messageId)
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug) SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug)
} }
} }
@@ -5107,7 +5241,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var noiseKey: Data? = nil var noiseKey: Data? = nil
// First try as hex-encoded Noise key (64 chars) // First try as hex-encoded Noise key (64 chars)
if peerID.count == 64 { if PeerIDResolver.isNoiseKeyHex(peerID) {
noiseKey = Data(hexString: peerID) noiseKey = Data(hexString: peerID)
} }
@@ -5157,7 +5291,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
// Check if we've read this message before (in a previous session) // Check if we've read this message before (in a previous session)
let wasReadBefore = sentReadReceipts.contains(messageId) let wasReadBefore = readReceiptTracker.contains(messageId)
// Try to find sender by checking all known peers for nickname matches // Try to find sender by checking all known peers for nickname matches
// This is a fallback when we receive Nostr messages from someone not in favorites // This is a fallback when we receive Nostr messages from someone not in favorites
@@ -5505,7 +5639,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// 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 PeerIDResolver.isShortID(peerID) { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: peerID) { if let peer = unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = peer.noisePublicKey.hexEncodedString() let stableKeyHex = peer.noisePublicKey.hexEncodedString()
@@ -5576,7 +5710,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if selectedPrivateChatPeer != peerID { if selectedPrivateChatPeer != peerID {
unreadPrivateMessages.insert(peerID) unreadPrivateMessages.insert(peerID)
// Avoid notifying for messages that have been marked read already (resubscribe/dup cases) // Avoid notifying for messages that have been marked read already (resubscribe/dup cases)
if !sentReadReceipts.contains(message.id) { if !readReceiptTracker.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
@@ -5592,7 +5726,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
cleanupStaleUnreadPeerIDs() cleanupStaleUnreadPeerIDs()
// Send read receipt if needed // Send read receipt if needed
if !sentReadReceipts.contains(message.id) { if !readReceiptTracker.contains(message.id) {
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
readerID: meshService.myPeerID, readerID: meshService.myPeerID,
@@ -5612,7 +5746,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport) self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
} }
sentReadReceipts.insert(message.id) readReceiptTracker.insert(message.id)
} }
// Mark other messages as read // Mark other messages as read
+4 -4
View File
@@ -1182,7 +1182,7 @@ struct ContentView: View {
private func privateHeaderContent(for privatePeerID: String) -> some View { private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = { let headerPeerID: String = {
if privatePeerID.count == 64 { if PeerIDResolver.isNoiseKeyHex(privatePeerID) {
// Map stable Noise key to short ID if we know it (even if not directly connected) // Map stable Noise key to short ID if we know it (even if not directly connected)
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short } if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
} }
@@ -1207,14 +1207,14 @@ struct ContentView: View {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if headerPeerID.count == 16 { if PeerIDResolver.isShortID(headerPeerID) {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first, if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname } if !social.claimedNickname.isEmpty { return social.claimedNickname }
} }
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { } else if PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint() let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet } if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1298,7 +1298,7 @@ struct ContentView: View {
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = { let statusPeerID: String = {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) { if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short return short
} }
return headerPeerID return headerPeerID
+2 -2
View File
@@ -43,14 +43,14 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: String = { let statusPeerID: String = {
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short } if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID return peerID
}() }()
// Resolve a friendly name // Resolve a friendly name
let peerNickname: String = { let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) { if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint() let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {