mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4c596a17 |
@@ -2,7 +2,7 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
||||
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
@@ -11,99 +11,50 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||
|
||||
|
||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
|
||||
- **Transport**: Bluetooth Low Energy mesh network
|
||||
- **Scope**: Local devices within multi-hop range
|
||||
- **Internet**: Not required
|
||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
||||
|
||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
||||
|
||||
- **Transport**: Nostr protocol over internet
|
||||
- **Scope**: Geographic areas defined by geohash precision
|
||||
- `block` (7 chars): City block level
|
||||
- `neighborhood` (6 chars): District/neighborhood
|
||||
- `city` (5 chars): City level
|
||||
- `province` (4 chars): State/province
|
||||
- `region` (2 chars): Country/large region
|
||||
- **Internet**: Required (connects to Nostr relays)
|
||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
||||
|
||||
### Direct Message Routing
|
||||
|
||||
Private messages use **intelligent transport selection**:
|
||||
|
||||
1. **Bluetooth First** (preferred when available)
|
||||
|
||||
- Direct connection with established Noise session
|
||||
- Fastest and most private option
|
||||
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
- Messages queued until transport becomes available
|
||||
- Automatic delivery when connection established
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
|
||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
### Option 2: Using Swift Package Manager
|
||||
|
||||
1. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
|
||||
@@ -56,12 +56,6 @@
|
||||
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.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 */; };
|
||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -529,9 +520,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
|
||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
|
||||
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||
@@ -755,9 +743,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
|
||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||
@@ -817,9 +802,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
|
||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||
|
||||
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// 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
|
||||
|
||||
extension Data {
|
||||
@@ -231,3 +220,4 @@ extension Data {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
|
||||
func isPeerConnected(_ peerID: String) -> Bool {
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
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 peerID
|
||||
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
|
||||
func isPeerReachable(_ peerID: String) -> Bool {
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
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 peerID
|
||||
@@ -664,7 +664,8 @@ final class BLEService: NSObject {
|
||||
return collectionsQueue.sync {
|
||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
// 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 {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
|
||||
@@ -4,12 +4,13 @@ import Foundation
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
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
|
||||
|
||||
init(mesh: Transport, nostr: NostrOutboxService) {
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -45,7 +46,7 @@ final class MessageRouter {
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
|
||||
@@ -548,7 +548,8 @@ class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
return npub
|
||||
}
|
||||
if PeerIDResolver.isShortID(peerID),
|
||||
if peerID.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
|
||||
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
|
||||
@Published var unreadMessages: Set<String> = []
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
private var readReceiptTracker: ReadReceiptTracker?
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
weak var meshService: Transport?
|
||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||
weak var messageRouter: MessageRouter?
|
||||
|
||||
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
self.readReceiptTracker = readReceiptTracker
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
@@ -122,7 +121,7 @@ class PrivateChatManager: ObservableObject {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// 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(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
@@ -161,7 +160,7 @@ class PrivateChatManager: ObservableObject {
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
if let messages = privateChats[peerID] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
guard !(readReceiptTracker?.contains(message.id) ?? false),
|
||||
guard !sentReadReceipts.contains(message.id),
|
||||
let senderPeerID = message.senderPeerID else {
|
||||
return
|
||||
}
|
||||
|
||||
readReceiptTracker?.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
// Create read receipt using the simplified method
|
||||
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
|
||||
|
||||
// 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()
|
||||
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
|
||||
} else {
|
||||
return "mesh:" + spid.lowercased()
|
||||
@@ -245,7 +245,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private let commandProcessor: CommandProcessor
|
||||
private let messageRouter: MessageRouter
|
||||
private let privateChatManager: PrivateChatManager
|
||||
private let readReceiptTracker: ReadReceiptTracker
|
||||
private let unifiedPeerService: UnifiedPeerService
|
||||
private let autocompleteService: AutocompleteService
|
||||
|
||||
@@ -309,7 +308,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
|
||||
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||||
// Fallback: derive from active Noise session if available
|
||||
if PeerIDResolver.isShortID(shortPeerID),
|
||||
if shortPeerID.count == 16,
|
||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||
let stable = key.hexEncodedString()
|
||||
shortIDToNoiseKey[shortPeerID] = stable
|
||||
@@ -348,7 +347,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
var meshService: Transport = BLEService()
|
||||
private var nostrRelayManager: NostrRelayManager?
|
||||
// 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 nicknameKey = "bitchat.nickname"
|
||||
// Location channel state (macOS supports manual geohash selection)
|
||||
@@ -357,8 +358,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
private var geoDmSubscriptionID: String? = nil
|
||||
private var currentGeohash: String? = nil
|
||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||
private var nostrInbox: NostrInboxService
|
||||
private var nostrOutbox: NostrOutboxService
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
@@ -436,7 +435,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published private(set) var isBatchingPublic: Bool = false
|
||||
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
|
||||
private var lastVerifyToastAt: [String: Date] = [:]
|
||||
@@ -456,15 +470,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
// Initialize read receipt tracker first
|
||||
self.readReceiptTracker = ReadReceiptTracker()
|
||||
// Load persisted read receipts
|
||||
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
|
||||
self.commandProcessor = CommandProcessor()
|
||||
self.privateChatManager = PrivateChatManager(meshService: meshService, readReceiptTracker: readReceiptTracker)
|
||||
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
||||
self.nostrInbox = NostrInboxService()
|
||||
self.nostrOutbox = NostrOutboxService(meshService: meshService)
|
||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: self.nostrOutbox)
|
||||
let nostrTransport = NostrTransport()
|
||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||
// Route receipts from PrivateChatManager through MessageRouter
|
||||
self.privateChatManager.messageRouter = self.messageRouter
|
||||
self.autocompleteService = AutocompleteService()
|
||||
@@ -693,198 +713,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
#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
|
||||
|
||||
deinit {
|
||||
@@ -904,15 +732,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
startGeoParticipantsTimer()
|
||||
// Unsubscribe + resubscribe
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
nostrInbox.subscribeGeohashEphemeral(
|
||||
geohash: ch.geohash,
|
||||
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
|
||||
limit: TransportConfig.nostrGeohashInitialLimit,
|
||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
||||
subID: subID
|
||||
) { [weak self] event in
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
ch.geohash,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashInitialLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
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 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,
|
||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
@@ -969,12 +802,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let dmSub = "geo-dm-\(ch.geohash)"
|
||||
geoDmSubscriptionID = dmSub
|
||||
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 }
|
||||
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 content.hasPrefix("bitchat1:") else { return }
|
||||
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
|
||||
@@ -990,7 +822,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let messageId = pm.messageID
|
||||
// Send delivery ACK immediately (once per message ID)
|
||||
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)
|
||||
}
|
||||
// Dedup storage
|
||||
@@ -999,7 +833,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let senderName = self.displayNameForNostrPubkey(senderPubkey)
|
||||
let isViewing = (self.selectedPrivateChatPeer == convKey)
|
||||
// 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 shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
let msg = BitchatMessage(
|
||||
@@ -1026,8 +860,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if isViewing {
|
||||
// pared back: omit pre-send READ log
|
||||
if !wasReadBefore {
|
||||
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.readReceiptTracker.insert(messageId)
|
||||
let nt = NostrTransport()
|
||||
nt.senderPeerID = self.meshService.myPeerID
|
||||
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
// Notify for truly unread and recent messages when not viewing
|
||||
@@ -1157,7 +993,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Ephemeral peer IDs are 8 bytes = 16 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)
|
||||
// Find the ephemeral peer ID for this Noise key
|
||||
let ephemeralPeerID = unifiedPeerService.peers.first { peer in
|
||||
@@ -1220,7 +1056,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@MainActor
|
||||
func isFavorite(peerID: String) -> Bool {
|
||||
// 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
|
||||
if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {
|
||||
return status.isFavorite
|
||||
@@ -1507,7 +1343,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
publicBuffer.removeAll(keepingCapacity: false)
|
||||
activeChannel = channel
|
||||
// Reset deduplication set and optionally hydrate timeline for mesh
|
||||
nostrInbox.reset()
|
||||
processedNostrEvents.removeAll()
|
||||
processedNostrEventOrder.removeAll()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
messages = meshTimeline
|
||||
@@ -1551,16 +1388,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
geoSubscriptionID = subID
|
||||
startGeoParticipantsTimer()
|
||||
nostrInbox.subscribeGeohashEphemeral(
|
||||
geohash: ch.geohash,
|
||||
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
|
||||
limit: TransportConfig.nostrGeohashInitialLimit,
|
||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
||||
subID: subID
|
||||
) { [weak self] event in
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
ch.geohash,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashInitialLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
// Only handle ephemeral kind 20000 with matching tag
|
||||
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
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
|
||||
@@ -1637,8 +1477,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// pared back logging: subscribe debug only
|
||||
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
|
||||
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 }
|
||||
// Dedup basic
|
||||
if self.processedNostrEvents.contains(giftWrap.id) { return }
|
||||
self.recordProcessedEvent(giftWrap.id)
|
||||
// Decrypt with per-geohash identity
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
|
||||
SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…",
|
||||
@@ -1663,7 +1507,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .info)
|
||||
// Send delivery ACK immediately (even if duplicate), once per 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)
|
||||
}
|
||||
// Duplicate check
|
||||
@@ -1671,7 +1518,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } }
|
||||
let senderName = self.displayNameForNostrPubkey(senderPubkey)
|
||||
let isViewing = (self.selectedPrivateChatPeer == convKey)
|
||||
let wasReadBefore = self.readReceiptTracker.contains(messageId)
|
||||
let wasReadBefore = self.sentReadReceipts.contains(messageId)
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
let msg = BitchatMessage(
|
||||
@@ -1695,8 +1542,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if isViewing {
|
||||
// pared back: omit pre-send READ log
|
||||
if !wasReadBefore {
|
||||
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.readReceiptTracker.insert(messageId)
|
||||
let nostrTransport = NostrTransport()
|
||||
nostrTransport.senderPeerID = self.meshService.myPeerID
|
||||
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
|
||||
self.sentReadReceipts.insert(messageId)
|
||||
}
|
||||
} else {
|
||||
// pared back: omit defer READ log
|
||||
@@ -1900,7 +1749,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
//
|
||||
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
|
||||
nostrInbox.unsubscribe(id: subID)
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
geoSamplingSubs.removeValue(forKey: subID)
|
||||
}
|
||||
|
||||
@@ -1908,13 +1757,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for gh in toAdd {
|
||||
let subID = "geo-sample-\(gh)"
|
||||
geoSamplingSubs[subID] = gh
|
||||
nostrInbox.subscribeGeohashEphemeral(
|
||||
geohash: gh,
|
||||
lookbackSeconds: TransportConfig.nostrGeohashSampleLookbackSeconds,
|
||||
limit: TransportConfig.nostrGeohashSampleLimit,
|
||||
relayCount: TransportConfig.nostrGeoRelayCount,
|
||||
subID: subID
|
||||
) { [weak self] event in
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
gh,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||
limit: TransportConfig.nostrGeohashSampleLimit
|
||||
)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// Update participants for this specific geohash
|
||||
@@ -1990,7 +1839,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// 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.
|
||||
/// - Parameters:
|
||||
@@ -2054,7 +1915,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
||||
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 }) {
|
||||
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
||||
}
|
||||
@@ -2279,7 +2142,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Never mark old messages as unread during consolidation
|
||||
if message.senderPeerID != meshService.myPeerID {
|
||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||
if messageAge < 60 && !readReceiptTracker.contains(message.id) {
|
||||
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
|
||||
hasActualUnreadMessages = true
|
||||
}
|
||||
}
|
||||
@@ -2400,7 +2263,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Delegate to private chat manager but add already-acked messages first
|
||||
// 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] {
|
||||
for message in messages {
|
||||
// Only track read receipts for messages WE sent (not received messages)
|
||||
@@ -2409,7 +2272,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .read, .delivered:
|
||||
readReceiptTracker.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
privateChatManager.sentReadReceipts.insert(message.id)
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -2763,11 +2627,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
let messages = privateChats[peerID] ?? []
|
||||
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))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
self.nostrOutbox.sendGeohashReadAck(messageID: message.id, toRecipientHex: recipientHex, from: id)
|
||||
readReceiptTracker.insert(message.id)
|
||||
let nostrTransport = NostrTransport()
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -2805,12 +2671,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Check both the ephemeral peer ID and stable Noise key as sender
|
||||
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
|
||||
// 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
|
||||
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)
|
||||
messageRouter.sendReadReceipt(receipt, to: recipPeer)
|
||||
readReceiptTracker.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2938,7 +2804,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
selectedPrivateChatFingerprint = nil
|
||||
|
||||
// Clear read receipt tracking
|
||||
readReceiptTracker.removeAll()
|
||||
sentReadReceipts.removeAll()
|
||||
processedNostrAcks.removeAll()
|
||||
|
||||
// Clear all caches
|
||||
@@ -3737,7 +3603,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}()
|
||||
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||
return getNostrPaletteColor(for: full, isDark: isDark)
|
||||
} else if PeerIDResolver.isShortID(spid) {
|
||||
} else if spid.count == 16 {
|
||||
// Mesh short ID
|
||||
return getPeerPaletteColor(for: spid, isDark: isDark)
|
||||
} else {
|
||||
@@ -4123,7 +3989,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// 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
|
||||
let isHexID = PeerIDResolver.isShortID(peerID) || PeerIDResolver.isNoiseKeyHex(peerID)
|
||||
let isHexID = peerID.allSatisfy { $0.isHexDigit }
|
||||
if !isHexID {
|
||||
// If it's already a nickname, just return it
|
||||
return peerID
|
||||
@@ -4571,10 +4437,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for message in messages {
|
||||
// Remove read receipts for messages FROM this peer (not TO this peer)
|
||||
if message.senderPeerID == peerID {
|
||||
// Prune single ID from tracker if needed
|
||||
// Note: ReadReceiptTracker persists asynchronously
|
||||
// Remove only if we want to allow resending READ later
|
||||
// For now keep as-is; leave this as a no-op
|
||||
sentReadReceipts.remove(message.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4677,7 +4540,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Don't remove stable Noise key hexes (64 char hex strings) that have messages
|
||||
// These are used for Nostr messages when peer is offline
|
||||
if PeerIDResolver.isNoiseKeyHex(staleID) {
|
||||
if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) {
|
||||
if let messages = privateChats[staleID], !messages.isEmpty {
|
||||
// Keep this ID - it's a stable key with messages
|
||||
continue
|
||||
@@ -4716,10 +4579,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// Remove receipts for messages we no longer have
|
||||
let before = readReceiptTracker.snapshot()
|
||||
readReceiptTracker.prune(toAllowedIDs: validMessageIDs)
|
||||
let after = readReceiptTracker.snapshot()
|
||||
let removedCount = before.count - after.count
|
||||
let oldCount = sentReadReceipts.count
|
||||
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
|
||||
|
||||
let removedCount = oldCount - sentReadReceipts.count
|
||||
if removedCount > 0 {
|
||||
SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
@@ -4905,11 +4768,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Subscribe to Nostr messages
|
||||
let _ = nostrInbox.subscribeGiftWrapsFor(
|
||||
pubkeyHex: currentIdentity.publicKeyHex,
|
||||
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
|
||||
subID: "chat-messages"
|
||||
) { [weak self] event in
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
||||
)
|
||||
|
||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
@@ -4918,7 +4782,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
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
|
||||
// Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes
|
||||
@@ -5003,7 +4869,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
if messageExistsLocally { return }
|
||||
|
||||
let wasReadBefore = readReceiptTracker.contains(messageId)
|
||||
let wasReadBefore = sentReadReceipts.contains(messageId)
|
||||
|
||||
// Is viewing?
|
||||
var isViewingThisChat = false
|
||||
@@ -5080,17 +4946,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
|
||||
unreadPrivateMessages.remove(ephemeralPeerID)
|
||||
}
|
||||
if !readReceiptTracker.contains(messageId) {
|
||||
if !sentReadReceipts.contains(messageId) {
|
||||
if let key = actualSenderNoiseKey {
|
||||
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)
|
||||
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
|
||||
readReceiptTracker.insert(messageId)
|
||||
sentReadReceipts.insert(messageId)
|
||||
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
||||
let nt = NostrTransport()
|
||||
nt.senderPeerID = meshService.myPeerID
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -5241,7 +5107,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
var noiseKey: Data? = nil
|
||||
|
||||
// First try as hex-encoded Noise key (64 chars)
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID) {
|
||||
if peerID.count == 64 {
|
||||
noiseKey = Data(hexString: peerID)
|
||||
}
|
||||
|
||||
@@ -5291,7 +5157,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
|
||||
// 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
|
||||
// This is a fallback when we receive Nostr messages from someone not in favorites
|
||||
@@ -5639,7 +5505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer
|
||||
// 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) {
|
||||
let stableKeyHex = peer.noisePublicKey.hexEncodedString()
|
||||
|
||||
@@ -5710,7 +5576,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if selectedPrivateChatPeer != peerID {
|
||||
unreadPrivateMessages.insert(peerID)
|
||||
// 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(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
@@ -5726,7 +5592,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
cleanupStaleUnreadPeerIDs()
|
||||
|
||||
// Send read receipt if needed
|
||||
if !readReceiptTracker.contains(message.id) {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService.myPeerID,
|
||||
@@ -5746,7 +5612,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
|
||||
}
|
||||
readReceiptTracker.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
}
|
||||
|
||||
// Mark other messages as read
|
||||
|
||||
@@ -1182,7 +1182,7 @@ struct ContentView: 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.
|
||||
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)
|
||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
||||
}
|
||||
@@ -1207,14 +1207,14 @@ struct ContentView: View {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||
if PeerIDResolver.isShortID(headerPeerID) {
|
||||
if headerPeerID.count == 16 {
|
||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let id = candidates.first,
|
||||
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
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()
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
@@ -1298,7 +1298,7 @@ struct ContentView: View {
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||
let statusPeerID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short
|
||||
}
|
||||
return headerPeerID
|
||||
|
||||
@@ -43,14 +43,14 @@ struct FingerprintView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
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
|
||||
}()
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
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 }
|
||||
let fp = data.sha256Fingerprint()
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||
|
||||
Reference in New Issue
Block a user