mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 03:45:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f20195c3 |
@@ -56,12 +56,6 @@
|
|||||||
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 */; };
|
||||||
@@ -219,9 +213,6 @@
|
|||||||
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>"; };
|
||||||
@@ -529,9 +520,6 @@
|
|||||||
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 */,
|
||||||
@@ -754,11 +742,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
|
|
||||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.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 */,
|
||||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
@@ -816,11 +801,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
|
|
||||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.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 */,
|
||||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.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 PeerIDResolver.isShortID(peerID) else { return [] }
|
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -36,16 +35,6 @@ 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 {
|
||||||
@@ -231,3 +220,4 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
if peerID.count == 64, 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 PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||||
}
|
}
|
||||||
return peerID
|
return peerID
|
||||||
@@ -664,7 +664,8 @@ 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)
|
||||||
return publicKey.sha256Fingerprint()
|
let hash = SHA256.hash(data: publicKey)
|
||||||
|
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 PeerIDResolver.isShortID(peerID) else { return nil }
|
guard peerID.count == 16 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 }
|
||||||
|
|||||||
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
teleportedSet = Set(arr)
|
teleportedSet = Set(arr)
|
||||||
}
|
}
|
||||||
// Initialize teleported flag from persisted state if a location channel is selected
|
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||||
if case .location(let ch) = selectedChannel {
|
// This avoids showing teleported for in-region channels during cold start.
|
||||||
teleported = teleportedSet.contains(ch.geohash)
|
|
||||||
}
|
|
||||||
let status: CLAuthorizationStatus
|
let status: CLAuthorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
status = cl.authorizationStatus
|
status = cl.authorizationStatus
|
||||||
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
status = CLLocationManager.authorizationStatus()
|
status = CLLocationManager.authorizationStatus()
|
||||||
}
|
}
|
||||||
updatePermissionState(from: status)
|
updatePermissionState(from: status)
|
||||||
|
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
break // will compute from location
|
||||||
|
default:
|
||||||
|
if case .location(let ch) = selectedChannel {
|
||||||
|
teleported = teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public API
|
// MARK: - Public API
|
||||||
@@ -129,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
case .mesh:
|
case .mesh:
|
||||||
self.teleported = false
|
self.teleported = false
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||||
|
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport for this geohash to keep future selections clean
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||||
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
|||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.availableChannels = result
|
self.availableChannels = result
|
||||||
// Recompute teleported status based on persisted state OR current location vs selected channel
|
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||||
switch self.selectedChannel {
|
switch self.selectedChannel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
self.teleported = false
|
self.teleported = false
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
let persisted = self.teleportedSet.contains(ch.geohash)
|
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||||
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||||
self.teleported = persisted || (currentGH != ch.geohash)
|
if inRegional {
|
||||||
|
self.teleported = false
|
||||||
|
// Clear persisted teleport flag if present
|
||||||
|
if self.teleportedSet.contains(ch.geohash) {
|
||||||
|
self.teleportedSet.remove(ch.geohash)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.teleported = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrOutboxService
|
private let nostr: NostrTransport
|
||||||
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: NostrOutboxService) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
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(
|
||||||
@@ -45,7 +46,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.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(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,7 +548,8 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||||
return publicKey.rawRepresentation.sha256Fingerprint()
|
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
|
|||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if PeerIDResolver.isShortID(peerID),
|
if peerID.count == 16,
|
||||||
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
|
||||||
|
|||||||
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
|
|||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<String> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
private var readReceiptTracker: ReadReceiptTracker?
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
|
|
||||||
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, readReceiptTracker: ReadReceiptTracker? = nil) {
|
init(meshService: Transport? = nil) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.readReceiptTracker = readReceiptTracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
@@ -122,7 +121,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 !(readReceiptTracker?.contains(message.id) ?? false) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
@@ -161,7 +160,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 && !(readReceiptTracker?.contains(message.id) ?? false) {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !(readReceiptTracker?.contains(message.id) ?? false),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeerID else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readReceiptTracker?.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
|
|
||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -412,4 +412,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Helper Extensions
|
// MARK: - Helper Extensions
|
||||||
|
|
||||||
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
|
extension Data {
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||||
|
let hash = SHA256.hash(data: self)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 PeerIDResolver.isShortID(spid), let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
} else {
|
} else {
|
||||||
return "mesh:" + spid.lowercased()
|
return "mesh:" + spid.lowercased()
|
||||||
@@ -245,7 +245,6 @@ 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
|
||||||
|
|
||||||
@@ -309,7 +308,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 PeerIDResolver.isShortID(shortPeerID),
|
if shortPeerID.count == 16,
|
||||||
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
|
||||||
@@ -348,7 +347,9 @@ 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
|
||||||
// NostrInboxService handles event de-duplication
|
private var processedNostrEvents = Set<String>() // Simple deduplication
|
||||||
|
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)
|
||||||
@@ -357,8 +358,6 @@ 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
|
||||||
|
|
||||||
@@ -436,7 +435,22 @@ 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
|
||||||
|
|
||||||
// Read receipts are centralized in ReadReceiptTracker
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
|
// 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] = [:]
|
||||||
@@ -456,15 +470,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init() {
|
init() {
|
||||||
// Initialize read receipt tracker first
|
// Load persisted read receipts
|
||||||
self.readReceiptTracker = ReadReceiptTracker()
|
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
|
||||||
|
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, readReceiptTracker: readReceiptTracker)
|
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
||||||
self.nostrInbox = NostrInboxService()
|
let nostrTransport = NostrTransport()
|
||||||
self.nostrOutbox = NostrOutboxService(meshService: meshService)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
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()
|
||||||
@@ -615,6 +635,26 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Track teleport flag changes to keep our own teleported marker in sync with regional status
|
||||||
|
LocationChannelManager.shared.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isTeleported in
|
||||||
|
guard let self = self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
guard case .location(let ch) = self.activeChannel,
|
||||||
|
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||||
|
let key = id.publicKeyHex.lowercased()
|
||||||
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if isTeleported && hasRegional && !inRegional {
|
||||||
|
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||||
|
} else {
|
||||||
|
self.teleportedGeo.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Request notification permission
|
// Request notification permission
|
||||||
NotificationService.shared.requestAuthorization()
|
NotificationService.shared.requestAuthorization()
|
||||||
|
|
||||||
@@ -693,198 +733,6 @@ 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 {
|
||||||
@@ -904,15 +752,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
// Unsubscribe + resubscribe
|
// Unsubscribe + resubscribe
|
||||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
nostrInbox.subscribeGeohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
geohash: ch.geohash,
|
ch.geohash,
|
||||||
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||||
limit: TransportConfig.nostrGeohashInitialLimit,
|
limit: TransportConfig.nostrGeohashInitialLimit
|
||||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
)
|
||||||
subID: subID
|
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
||||||
) { [weak self] event in
|
toGeohash: ch.geohash,
|
||||||
|
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() {
|
||||||
@@ -939,10 +792,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
})
|
})
|
||||||
if hasTeleportTag {
|
if hasTeleportTag {
|
||||||
let key = event.pubkey.lowercased()
|
let key = event.pubkey.lowercased()
|
||||||
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||||
|
let isSelf: Bool = {
|
||||||
|
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||||
|
return my.publicKeyHex.lowercased() == key
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
if !isSelf {
|
||||||
|
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let mentions = self.parseMentions(from: content)
|
let mentions = self.parseMentions(from: content)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
@@ -969,12 +831,11 @@ 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
|
||||||
nostrInbox.subscribeGiftWrapsFor(
|
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||||
pubkeyHex: id.publicKeyHex,
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
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))),
|
||||||
@@ -990,7 +851,9 @@ 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) {
|
||||||
self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
let nt = NostrTransport()
|
||||||
|
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
|
||||||
@@ -999,7 +862,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.readReceiptTracker.contains(messageId)
|
let wasReadBefore = self.sentReadReceipts.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(
|
||||||
@@ -1026,8 +889,10 @@ 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 {
|
||||||
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
let nt = NostrTransport()
|
||||||
self.readReceiptTracker.insert(messageId)
|
nt.senderPeerID = self.meshService.myPeerID
|
||||||
|
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
|
||||||
@@ -1157,7 +1022,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 PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
|
if peerID.count == 64, 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
|
||||||
@@ -1220,7 +1085,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 PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
|
if peerID.count == 64, 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
|
||||||
@@ -1381,7 +1246,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
/// Routes to private chat if one is selected, otherwise broadcasts
|
/// Routes to private chat if one is selected, otherwise broadcasts
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendMessage(_ content: String) {
|
func sendMessage(_ content: String) {
|
||||||
guard !content.isEmpty else { return }
|
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||||
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if content.hasPrefix("/") {
|
if content.hasPrefix("/") {
|
||||||
@@ -1401,7 +1268,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Parse mentions from the content
|
// Parse mentions from the content (use original content for user intent)
|
||||||
let mentions = parseMentions(from: content)
|
let mentions = parseMentions(from: content)
|
||||||
|
|
||||||
// Add message to local display
|
// Add message to local display
|
||||||
@@ -1416,7 +1283,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
sender: displaySender,
|
sender: displaySender,
|
||||||
content: content,
|
content: trimmed,
|
||||||
timestamp: Date(),
|
timestamp: Date(),
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -1461,7 +1328,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: content,
|
content: trimmed,
|
||||||
geohash: ch.geohash,
|
geohash: ch.geohash,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: self.nickname,
|
nickname: self.nickname,
|
||||||
@@ -1481,7 +1348,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||||
if LocationChannelManager.shared.teleported {
|
// Only when not in our regional set (and regional list is known)
|
||||||
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||||
let key = identity.publicKeyHex.lowercased()
|
let key = identity.publicKeyHex.lowercased()
|
||||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||||
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||||
@@ -1507,7 +1377,8 @@ 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
|
||||||
nostrInbox.reset()
|
processedNostrEvents.removeAll()
|
||||||
|
processedNostrEventOrder.removeAll()
|
||||||
switch channel {
|
switch channel {
|
||||||
case .mesh:
|
case .mesh:
|
||||||
messages = meshTimeline
|
messages = meshTimeline
|
||||||
@@ -1517,9 +1388,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Sanitize existing timeline (filter any prior empty-content entries)
|
// Sanitize existing timeline (filter any prior empty-content entries)
|
||||||
var arr = geoTimelines[ch.geohash] ?? []
|
var arr = geoTimelines[ch.geohash] ?? []
|
||||||
let before = arr.count
|
|
||||||
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
if arr.count != before { geoTimelines[ch.geohash] = arr }
|
// Ensure chronological order when returning to a geohash
|
||||||
|
if arr.count > 1 {
|
||||||
|
arr.sort { $0.timestamp < $1.timestamp }
|
||||||
|
}
|
||||||
|
// Persist the cleaned/sorted timeline for this geohash
|
||||||
|
geoTimelines[ch.geohash] = arr
|
||||||
messages = arr
|
messages = arr
|
||||||
}
|
}
|
||||||
// Unsubscribe previous
|
// Unsubscribe previous
|
||||||
@@ -1538,29 +1413,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard case .location(let ch) = channel else { return }
|
guard case .location(let ch) = channel else { return }
|
||||||
currentGeohash = ch.geohash
|
currentGeohash = ch.geohash
|
||||||
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
||||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||||
if LocationChannelManager.shared.teleported {
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
let key = id.publicKeyHex.lowercased()
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
|
let key = id.publicKeyHex.lowercased()
|
||||||
|
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||||
teleportedGeo = teleportedGeo.union([key])
|
teleportedGeo = teleportedGeo.union([key])
|
||||||
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
|
} else {
|
||||||
|
teleportedGeo.remove(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subID = "geo-\(ch.geohash)"
|
let subID = "geo-\(ch.geohash)"
|
||||||
geoSubscriptionID = subID
|
geoSubscriptionID = subID
|
||||||
startGeoParticipantsTimer()
|
startGeoParticipantsTimer()
|
||||||
nostrInbox.subscribeGeohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
geohash: ch.geohash,
|
ch.geohash,
|
||||||
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||||
limit: TransportConfig.nostrGeohashInitialLimit,
|
limit: TransportConfig.nostrGeohashInitialLimit
|
||||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
)
|
||||||
subID: subID
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||||
) { [weak self] event in
|
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 }
|
||||||
// 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)",
|
||||||
@@ -1571,10 +1453,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
})
|
})
|
||||||
if hasTeleportTag {
|
if hasTeleportTag {
|
||||||
let key = event.pubkey.lowercased()
|
let key = event.pubkey.lowercased()
|
||||||
Task { @MainActor in
|
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
let isSelf: Bool = {
|
||||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||||
category: SecureLogger.session, level: .info)
|
return my.publicKeyHex.lowercased() == key
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
if !isSelf {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||||
|
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||||
|
category: SecureLogger.session, level: .info)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
@@ -1637,8 +1528,12 @@ 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)
|
||||||
nostrInbox.subscribeGiftWrapsFor(pubkeyHex: id.publicKeyHex, lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds, subID: dmSub) { [weak self] giftWrap in
|
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
||||||
|
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))…",
|
||||||
@@ -1663,7 +1558,10 @@ 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) {
|
||||||
self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
let nostrTransport = NostrTransport()
|
||||||
|
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
|
||||||
@@ -1671,7 +1569,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.readReceiptTracker.contains(messageId)
|
let wasReadBefore = self.sentReadReceipts.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(
|
||||||
@@ -1695,8 +1593,10 @@ 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 {
|
||||||
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
let nostrTransport = NostrTransport()
|
||||||
self.readReceiptTracker.insert(messageId)
|
nostrTransport.senderPeerID = self.meshService.myPeerID
|
||||||
|
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
|
||||||
@@ -1900,7 +1800,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
//
|
//
|
||||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||||
nostrInbox.unsubscribe(id: subID)
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
geoSamplingSubs.removeValue(forKey: subID)
|
geoSamplingSubs.removeValue(forKey: subID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1908,24 +1808,35 @@ 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
|
||||||
nostrInbox.subscribeGeohashEphemeral(
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
geohash: gh,
|
gh,
|
||||||
lookbackSeconds: TransportConfig.nostrGeohashSampleLookbackSeconds,
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||||
limit: TransportConfig.nostrGeohashSampleLimit,
|
limit: TransportConfig.nostrGeohashSampleLimit
|
||||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
)
|
||||||
subID: subID
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||||
) { [weak self] event in
|
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 }
|
||||||
|
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||||
|
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||||
|
let existingCount: Int = {
|
||||||
|
let map = self.geoParticipants[gh] ?? [:]
|
||||||
|
return map.values.filter { $0 >= cutoff }.count
|
||||||
|
}()
|
||||||
// Update participants for this specific geohash
|
// Update participants for this specific geohash
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||||
// Notify on new message activity in this geohash (sampling across channels)
|
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
// Respect geohash blocks
|
// Respect geohash blocks
|
||||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||||
// Skip self identity for this geohash
|
// Skip self identity for this geohash
|
||||||
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||||
|
// Only trigger when there were zero participants in this geohash recently
|
||||||
|
guard existingCount == 0 else { return }
|
||||||
|
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||||
// Foreground policy: allow if it's a different geohash than the one currently open
|
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||||
// Suppress only when app is active AND we're already in this same geohash channel
|
// Suppress only when app is active AND we're already in this same geohash channel
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -1990,7 +1901,19 @@ 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:
|
||||||
@@ -2054,7 +1977,9 @@ 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)
|
||||||
self.nostrOutbox.sendGeohashPM(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
let nostrTransport = NostrTransport()
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -2279,7 +2204,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 && !readReceiptTracker.contains(message.id) {
|
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
|
||||||
hasActualUnreadMessages = true
|
hasActualUnreadMessages = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2400,7 +2325,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 readReceiptTracker, not messages we received
|
// IMPORTANT: Only add messages WE sent to sentReadReceipts, 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)
|
||||||
@@ -2409,7 +2334,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let status = message.deliveryStatus {
|
if let status = message.deliveryStatus {
|
||||||
switch status {
|
switch status {
|
||||||
case .read, .delivered:
|
case .read, .delivered:
|
||||||
readReceiptTracker.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
|
privateChatManager.sentReadReceipts.insert(message.id)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -2763,11 +2689,13 @@ 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 !readReceiptTracker.contains(message.id) {
|
if !sentReadReceipts.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)
|
||||||
self.nostrOutbox.sendGeohashReadAck(messageID: message.id, toRecipientHex: recipientHex, from: id)
|
let nostrTransport = NostrTransport()
|
||||||
readReceiptTracker.insert(message.id)
|
nostrTransport.senderPeerID = meshService.myPeerID
|
||||||
|
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
||||||
|
sentReadReceipts.insert(message.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -2805,12 +2733,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 !readReceiptTracker.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
// Use stable Noise key hex if available; else fall back to peerID
|
// Use stable Noise key hex if available; else fall back to peerID
|
||||||
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.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)
|
||||||
readReceiptTracker.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2938,7 +2866,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
selectedPrivateChatFingerprint = nil
|
selectedPrivateChatFingerprint = nil
|
||||||
|
|
||||||
// Clear read receipt tracking
|
// Clear read receipt tracking
|
||||||
readReceiptTracker.removeAll()
|
sentReadReceipts.removeAll()
|
||||||
processedNostrAcks.removeAll()
|
processedNostrAcks.removeAll()
|
||||||
|
|
||||||
// Clear all caches
|
// Clear all caches
|
||||||
@@ -3737,7 +3665,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 PeerIDResolver.isShortID(spid) {
|
} else if spid.count == 16 {
|
||||||
// Mesh short ID
|
// Mesh short ID
|
||||||
return getPeerPaletteColor(for: spid, isDark: isDark)
|
return getPeerPaletteColor(for: spid, isDark: isDark)
|
||||||
} else {
|
} else {
|
||||||
@@ -4123,7 +4051,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 = PeerIDResolver.isShortID(peerID) || PeerIDResolver.isNoiseKeyHex(peerID)
|
let isHexID = peerID.allSatisfy { $0.isHexDigit }
|
||||||
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
|
||||||
@@ -4422,11 +4350,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let publicMentions = parseMentions(from: content)
|
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let publicMentions = parseMentions(from: normalized)
|
||||||
let msg = BitchatMessage(
|
let msg = BitchatMessage(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
sender: nickname,
|
sender: nickname,
|
||||||
content: content,
|
content: normalized,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -4571,10 +4500,7 @@ 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 {
|
||||||
// Prune single ID from tracker if needed
|
sentReadReceipts.remove(message.id)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4677,7 +4603,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 PeerIDResolver.isNoiseKeyHex(staleID) {
|
if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) {
|
||||||
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
|
||||||
@@ -4716,10 +4642,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove receipts for messages we no longer have
|
// Remove receipts for messages we no longer have
|
||||||
let before = readReceiptTracker.snapshot()
|
let oldCount = sentReadReceipts.count
|
||||||
readReceiptTracker.prune(toAllowedIDs: validMessageIDs)
|
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||||
let after = readReceiptTracker.snapshot()
|
|
||||||
let removedCount = before.count - after.count
|
let removedCount = oldCount - sentReadReceipts.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)
|
||||||
@@ -4905,11 +4831,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
|
|
||||||
// Subscribe to Nostr messages
|
// Subscribe to Nostr messages
|
||||||
let _ = nostrInbox.subscribeGiftWrapsFor(
|
let filter = NostrFilter.giftWrapsFor(
|
||||||
pubkeyHex: currentIdentity.publicKeyHex,
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -4918,7 +4845,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleNostrMessage(_ giftWrap: NostrEvent) {
|
private func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
// De-duplication handled by NostrInboxService
|
// Simple deduplication
|
||||||
|
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
|
||||||
@@ -5003,7 +4932,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
if messageExistsLocally { return }
|
if messageExistsLocally { return }
|
||||||
|
|
||||||
let wasReadBefore = readReceiptTracker.contains(messageId)
|
let wasReadBefore = sentReadReceipts.contains(messageId)
|
||||||
|
|
||||||
// Is viewing?
|
// Is viewing?
|
||||||
var isViewingThisChat = false
|
var isViewingThisChat = false
|
||||||
@@ -5080,17 +5009,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 !readReceiptTracker.contains(messageId) {
|
if !sentReadReceipts.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())
|
||||||
readReceiptTracker.insert(messageId)
|
sentReadReceipts.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)
|
||||||
readReceiptTracker.insert(messageId)
|
sentReadReceipts.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5241,7 +5170,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 PeerIDResolver.isNoiseKeyHex(peerID) {
|
if peerID.count == 64 {
|
||||||
noiseKey = Data(hexString: peerID)
|
noiseKey = Data(hexString: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5291,7 +5220,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 = readReceiptTracker.contains(messageId)
|
let wasReadBefore = sentReadReceipts.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
|
||||||
@@ -5639,7 +5568,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 PeerIDResolver.isShortID(peerID) { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
|
if peerID.count == 16 { // 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()
|
||||||
|
|
||||||
@@ -5710,7 +5639,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 !readReceiptTracker.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
@@ -5726,7 +5655,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
cleanupStaleUnreadPeerIDs()
|
cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
// Send read receipt if needed
|
// Send read receipt if needed
|
||||||
if !readReceiptTracker.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService.myPeerID,
|
readerID: meshService.myPeerID,
|
||||||
@@ -5746,7 +5675,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
|
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
|
||||||
}
|
}
|
||||||
readReceiptTracker.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark other messages as read
|
// Mark other messages as read
|
||||||
@@ -5811,34 +5740,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard channelMatches else { return }
|
guard channelMatches else { return }
|
||||||
|
|
||||||
// Background nudge: notify on new activity after inactivity threshold in current channel
|
|
||||||
#if os(iOS)
|
|
||||||
if UIApplication.shared.applicationState != .active {
|
|
||||||
let channelKey: String = {
|
|
||||||
switch activeChannel {
|
|
||||||
case .mesh: return "mesh"
|
|
||||||
case .location(let ch): return "geo:\(ch.geohash)"
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
let now = Date()
|
|
||||||
if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold {
|
|
||||||
// Optional: simple cooldown to avoid duplicate bursts
|
|
||||||
let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast
|
|
||||||
if now.timeIntervalSince(lastNotified) >= 60 {
|
|
||||||
let title = activeChannelDisplayName()
|
|
||||||
let body = "new chats!"
|
|
||||||
if case .location(let ch) = activeChannel {
|
|
||||||
// Attach deep link to open this geohash directly
|
|
||||||
NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body)
|
|
||||||
} else {
|
|
||||||
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)")
|
|
||||||
}
|
|
||||||
lastPublicActivityNotifyAt[channelKey] = now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastPublicActivityAt[channelKey] = now
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Append via batching buffer (skip empty content)
|
// Append via batching buffer (skip empty content)
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
|||||||
@@ -459,7 +459,13 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
let level = levelForLength(gh.count)
|
let level = levelForLength(gh.count)
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
// Do not mark teleported when opening a geohash that is in our regional set.
|
||||||
|
// If availableChannels is empty (e.g., cold start), defer marking and let
|
||||||
|
// LocationChannelManager compute teleported based on actual location.
|
||||||
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||||
|
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||||
|
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||||
|
}
|
||||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||||
}
|
}
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
@@ -1182,7 +1188,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 PeerIDResolver.isNoiseKeyHex(privatePeerID) {
|
if privatePeerID.count == 64 {
|
||||||
// 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 +1213,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 PeerIDResolver.isShortID(headerPeerID) {
|
if headerPeerID.count == 16 {
|
||||||
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 PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) {
|
} else if headerPeerID.count == 64, 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 +1304,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 PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||||
return short
|
return short
|
||||||
}
|
}
|
||||||
return headerPeerID
|
return headerPeerID
|
||||||
|
|||||||
@@ -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 PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
if peerID.count == 64, 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 PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
|
if peerID.count == 64, 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user