Compare commits

..
Author SHA1 Message Date
jack 4d4c596a17 UI: diversify peer colors; smarter geo notifications; 21m live location
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.
2025-08-26 18:39:48 +02:00
17 changed files with 203 additions and 624 deletions
+16 -66
View File
@@ -2,7 +2,7 @@
## bitchat ## 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) [bitchat.free](http://bitchat.free)
@@ -11,99 +11,50 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
> [!WARNING] > [!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. > 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 ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details. This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
## Features ## 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 - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **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 - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking - **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat) ## [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) ### Mesh Networking
- Each device acts as both client and peripheral
- **Local Communication**: Direct peer-to-peer within Bluetooth range - Automatic peer discovery and connection management
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops) - Adaptive duty cycling for battery optimization
- **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
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md). For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
## Setup ## Setup
### Option 1: Using XcodeGen (Recommended) ### Option 1: Using XcodeGen (Recommended)
1. Install XcodeGen if you haven't already: 1. Install XcodeGen if you haven't already:
```bash ```bash
brew install xcodegen brew install xcodegen
``` ```
2. Generate the Xcode project: 2. Generate the Xcode project:
```bash ```bash
cd bitchat cd bitchat
xcodegen generate xcodegen generate
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 2: Using Swift Package Manager ### Option 2: Using Swift Package Manager
1. Open the project in Xcode: 1. Open the project in Xcode:
```bash ```bash
cd bitchat cd bitchat
open Package.swift open Package.swift
+4 -22
View File
@@ -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) }
} }
} }
+1 -11
View File
@@ -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
} }
} }
+4 -3
View File
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool { func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys // Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = { let shortID: String = {
if 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 }
+4 -3
View File
@@ -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
-90
View File
@@ -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)
}
}
-50
View File
@@ -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)
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if 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
+6 -7
View File
@@ -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(
-77
View File
@@ -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)
}
}
}
+7 -1
View File
@@ -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()
}
}
+148 -282
View File
@@ -161,7 +161,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}() }()
let full = (nostrKeyMapping[spid] ?? bare).lowercased() let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full return "nostr:" + full
} else if 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()
@@ -693,198 +713,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 +732,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() {
@@ -969,12 +802,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 +822,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 +833,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 +860,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 +993,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 +1056,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
@@ -1507,7 +1343,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
@@ -1551,16 +1388,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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)",
@@ -1637,8 +1477,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 +1507,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 +1518,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 +1542,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 +1749,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,13 +1757,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for gh in toAdd { for gh in toAdd {
let subID = "geo-sample-\(gh)" let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh geoSamplingSubs[subID] = gh
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 }
// Update participants for this specific geohash // Update participants for this specific geohash
@@ -1990,7 +1839,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 +1915,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 +2142,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 +2263,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 +2272,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 +2627,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 +2671,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 +2804,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 +3603,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 +3989,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
@@ -4571,10 +4437,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 +4540,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 +4579,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 +4768,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 +4782,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 +4869,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 +4946,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 +5107,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 +5157,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 +5505,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 +5576,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 +5592,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 +5612,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
+4 -4
View File
@@ -1182,7 +1182,7 @@ struct ContentView: View {
private func privateHeaderContent(for privatePeerID: String) -> some View { private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = { let headerPeerID: String = {
if 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 +1207,14 @@ struct ContentView: View {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()), if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname } !fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping // Fallback: resolve from persisted social identity via fingerprint mapping
if 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 +1298,7 @@ struct ContentView: View {
if !privatePeerID.hasPrefix("nostr_") { if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID) // Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = { let statusPeerID: String = {
if 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
+2 -2
View File
@@ -43,14 +43,14 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status // Prefer short mesh ID for session/encryption status
let statusPeerID: String = { let statusPeerID: String = {
if 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) {