mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:05:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4c596a17 |
@@ -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
|
||||||
|
|||||||
@@ -56,12 +56,6 @@
|
|||||||
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
||||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
|
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
|
||||||
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
||||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
|
|
||||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
|
|
||||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
|
|
||||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
|
|
||||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
|
|
||||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
|
|
||||||
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
||||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||||
@@ -219,9 +213,6 @@
|
|||||||
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
|
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
|
||||||
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
|
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
|
||||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
|
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
|
||||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
|
|
||||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
|
|
||||||
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
|
|
||||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
|
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
|
||||||
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
|
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
|
||||||
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
|
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
|
||||||
@@ -529,9 +520,6 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
|
|
||||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
|
|
||||||
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
|
|
||||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||||
047502B82E560F690083520F /* RelayController.swift */,
|
047502B82E560F690083520F /* RelayController.swift */,
|
||||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||||
@@ -754,11 +742,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
|
|
||||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
|
|
||||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
|
||||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
@@ -816,11 +801,8 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
|
|
||||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
|
|
||||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
|
||||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||||
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
|
|||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard PeerIDResolver.isShortID(peerID) else { return [] }
|
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Hex Encoding/Decoding
|
// MARK: - Hex Encoding/Decoding
|
||||||
|
|
||||||
@@ -36,16 +35,6 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fingerprint Helpers
|
|
||||||
|
|
||||||
extension Data {
|
|
||||||
/// SHA-256 over data, hex-encoded (lowercase)
|
|
||||||
func sha256Fingerprint() -> String {
|
|
||||||
let hash = SHA256.hash(data: self)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Encoding Utilities
|
// MARK: - Binary Encoding Utilities
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
@@ -231,3 +220,4 @@ extension Data {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
|
|||||||
func isPeerConnected(_ peerID: String) -> Bool {
|
func isPeerConnected(_ peerID: String) -> Bool {
|
||||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||||
let shortID: String = {
|
let shortID: String = {
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||||
}
|
}
|
||||||
return peerID
|
return peerID
|
||||||
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
|
|||||||
func isPeerReachable(_ peerID: String) -> Bool {
|
func isPeerReachable(_ peerID: String) -> Bool {
|
||||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||||
let shortID: String = {
|
let shortID: String = {
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||||
}
|
}
|
||||||
return peerID
|
return peerID
|
||||||
@@ -664,7 +664,8 @@ final class BLEService: NSObject {
|
|||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||||
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
||||||
return publicKey.sha256Fingerprint()
|
let hash = SHA256.hash(data: publicKey)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
|
|||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard PeerIDResolver.isShortID(peerID) else { return nil }
|
guard peerID.count == 16 else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||||
if derived == peerID { return rel }
|
if derived == peerID { return rel }
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrOutboxService
|
private let nostr: NostrTransport
|
||||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
init(mesh: Transport, nostr: NostrOutboxService) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
self.nostr = nostr
|
self.nostr = nostr
|
||||||
|
self.nostr.senderPeerID = mesh.myPeerID
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -45,7 +46,7 @@ final class MessageRouter {
|
|||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||||
category: SecureLogger.session, level: .debug)
|
category: SecureLogger.session, level: .debug)
|
||||||
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
|
|||||||
@@ -548,7 +548,8 @@ class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||||
return publicKey.rawRepresentation.sha256Fingerprint()
|
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
//
|
|
||||||
// NostrInboxService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class NostrInboxService {
|
|
||||||
private var processedIDs: Set<String> = []
|
|
||||||
private var order: [String] = []
|
|
||||||
private let cap: Int
|
|
||||||
|
|
||||||
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
|
|
||||||
self.cap = capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
private func track(_ id: String) -> Bool {
|
|
||||||
if processedIDs.contains(id) { return false }
|
|
||||||
processedIDs.insert(id)
|
|
||||||
order.append(id)
|
|
||||||
if order.count > cap {
|
|
||||||
let overflow = order.count - cap
|
|
||||||
for _ in 0..<overflow {
|
|
||||||
if let oldest = order.first {
|
|
||||||
order.removeFirst()
|
|
||||||
processedIDs.remove(oldest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
processedIDs.removeAll()
|
|
||||||
order.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to public geohash ephemeral events (kind 20000)
|
|
||||||
@discardableResult
|
|
||||||
func subscribeGeohashEphemeral(
|
|
||||||
geohash: String,
|
|
||||||
lookbackSeconds: TimeInterval,
|
|
||||||
limit: Int,
|
|
||||||
relayCount: Int,
|
|
||||||
subID: String? = nil,
|
|
||||||
handler: @escaping (NostrEvent) -> Void
|
|
||||||
) -> String {
|
|
||||||
let id = subID ?? "geo-\(geohash)"
|
|
||||||
let filter = NostrFilter.geohashEphemeral(
|
|
||||||
geohash,
|
|
||||||
since: Date().addingTimeInterval(-lookbackSeconds),
|
|
||||||
limit: limit
|
|
||||||
)
|
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
|
||||||
guard self.track(event.id) else { return }
|
|
||||||
handler(event)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to gift wraps (DMs) for a given pubkey
|
|
||||||
@discardableResult
|
|
||||||
func subscribeGiftWrapsFor(
|
|
||||||
pubkeyHex: String,
|
|
||||||
lookbackSeconds: TimeInterval,
|
|
||||||
subID: String? = nil,
|
|
||||||
handler: @escaping (NostrEvent) -> Void
|
|
||||||
) -> String {
|
|
||||||
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
|
|
||||||
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard self.track(event.id) else { return }
|
|
||||||
handler(event)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
func unsubscribe(id: String) {
|
|
||||||
NostrRelayManager.shared.unsubscribe(id: id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
//
|
|
||||||
// NostrOutboxService.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Centralized Nostr outbox for sending PMs and ACKs.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class NostrOutboxService {
|
|
||||||
private let transport: NostrTransport
|
|
||||||
|
|
||||||
init(meshService: Transport) {
|
|
||||||
let t = NostrTransport()
|
|
||||||
t.senderPeerID = meshService.myPeerID
|
|
||||||
self.transport = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
||||||
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Generic outbox via favorites mapping
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
|
||||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
|
||||||
transport.sendReadReceipt(receipt, to: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
|
||||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
|
||||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
|
|||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if PeerIDResolver.isShortID(peerID),
|
if peerID.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
|
|||||||
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
|
|||||||
@Published var unreadMessages: Set<String> = []
|
@Published var unreadMessages: Set<String> = []
|
||||||
|
|
||||||
private var selectedPeerFingerprint: String? = nil
|
private var selectedPeerFingerprint: String? = nil
|
||||||
private var readReceiptTracker: ReadReceiptTracker?
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
|
|
||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
|
|
||||||
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
|
init(meshService: Transport? = nil) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
self.readReceiptTracker = readReceiptTracker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
@@ -122,7 +121,7 @@ class PrivateChatManager: ObservableObject {
|
|||||||
unreadMessages.insert(senderPeerID)
|
unreadMessages.insert(senderPeerID)
|
||||||
|
|
||||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||||
if !(readReceiptTracker?.contains(message.id) ?? false) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
@@ -161,7 +160,7 @@ class PrivateChatManager: ObservableObject {
|
|||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
|
|||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !(readReceiptTracker?.contains(message.id) ?? false),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeerID else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
readReceiptTracker?.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
|
|
||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
//
|
|
||||||
// ReadReceiptTracker.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Centralized tracker for sent read receipts with simple persistence.
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
final class ReadReceiptTracker {
|
|
||||||
private let defaults: UserDefaults
|
|
||||||
private let key = "sentReadReceipts"
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
|
|
||||||
private var set: Set<String> = []
|
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard) {
|
|
||||||
self.defaults = defaults
|
|
||||||
if let data = defaults.data(forKey: key),
|
|
||||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
||||||
self.set = Set(arr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
queue.sync { set.contains(id) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func insert(_ id: String) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
if self.set.insert(id).inserted { self.persist() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func insert<S: Sequence>(_ ids: S) where S.Element == String {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
var changed = false
|
|
||||||
for id in ids { changed = self.set.insert(id).inserted || changed }
|
|
||||||
if changed { self.persist() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(_ id: String) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
if self.set.remove(id) != nil { self.persist() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
|
|
||||||
func prune(toAllowedIDs allowed: Set<String>) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
let newSet = self.set.intersection(allowed)
|
|
||||||
if newSet.count != self.set.count { self.set = newSet; self.persist() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
|
|
||||||
func snapshot() -> Set<String> { queue.sync { set } }
|
|
||||||
|
|
||||||
private func persist() {
|
|
||||||
let arr = Array(set)
|
|
||||||
if let data = try? JSONEncoder().encode(arr) {
|
|
||||||
defaults.set(data, forKey: key)
|
|
||||||
} else {
|
|
||||||
SecureLogger.log("❌ Failed to encode read receipts for persistence",
|
|
||||||
category: SecureLogger.session, level: .error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -412,4 +412,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Helper Extensions
|
// MARK: - Helper Extensions
|
||||||
|
|
||||||
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
|
extension Data {
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||||
|
let hash = SHA256.hash(data: self)
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}()
|
}()
|
||||||
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
|
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
|
||||||
return "nostr:" + full
|
return "nostr:" + full
|
||||||
} else if PeerIDResolver.isShortID(spid), let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
||||||
return "noise:" + full
|
return "noise:" + full
|
||||||
} else {
|
} else {
|
||||||
return "mesh:" + spid.lowercased()
|
return "mesh:" + spid.lowercased()
|
||||||
@@ -245,7 +245,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private let commandProcessor: CommandProcessor
|
private let commandProcessor: CommandProcessor
|
||||||
private let messageRouter: MessageRouter
|
private let messageRouter: MessageRouter
|
||||||
private let privateChatManager: PrivateChatManager
|
private let privateChatManager: PrivateChatManager
|
||||||
private let readReceiptTracker: ReadReceiptTracker
|
|
||||||
private let unifiedPeerService: UnifiedPeerService
|
private let unifiedPeerService: UnifiedPeerService
|
||||||
private let autocompleteService: AutocompleteService
|
private let autocompleteService: AutocompleteService
|
||||||
|
|
||||||
@@ -309,7 +308,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
|
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
|
||||||
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
|
||||||
// Fallback: derive from active Noise session if available
|
// Fallback: derive from active Noise session if available
|
||||||
if PeerIDResolver.isShortID(shortPeerID),
|
if shortPeerID.count == 16,
|
||||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||||
let stable = key.hexEncodedString()
|
let stable = key.hexEncodedString()
|
||||||
shortIDToNoiseKey[shortPeerID] = stable
|
shortIDToNoiseKey[shortPeerID] = stable
|
||||||
@@ -348,7 +347,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
var meshService: Transport = BLEService()
|
var meshService: Transport = BLEService()
|
||||||
private var nostrRelayManager: NostrRelayManager?
|
private var nostrRelayManager: NostrRelayManager?
|
||||||
// PeerManager replaced by UnifiedPeerService
|
// PeerManager replaced by UnifiedPeerService
|
||||||
// NostrInboxService handles event de-duplication
|
private var processedNostrEvents = Set<String>() // Simple deduplication
|
||||||
|
private var processedNostrEventOrder: [String] = []
|
||||||
|
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
// Location channel state (macOS supports manual geohash selection)
|
// Location channel state (macOS supports manual geohash selection)
|
||||||
@@ -357,8 +358,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private var geoDmSubscriptionID: String? = nil
|
private var geoDmSubscriptionID: String? = nil
|
||||||
private var currentGeohash: String? = nil
|
private var currentGeohash: String? = nil
|
||||||
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
|
||||||
private var nostrInbox: NostrInboxService
|
|
||||||
private var nostrOutbox: NostrOutboxService
|
|
||||||
|
|
||||||
// MARK: - Caches
|
// MARK: - Caches
|
||||||
|
|
||||||
@@ -436,7 +435,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@Published private(set) var isBatchingPublic: Bool = false
|
@Published private(set) var isBatchingPublic: Bool = false
|
||||||
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
|
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
|
||||||
|
|
||||||
// Read receipts are centralized in ReadReceiptTracker
|
// Track sent read receipts to avoid duplicates (persisted across launches)
|
||||||
|
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
|
||||||
|
private var sentReadReceipts: Set<String> = [] { // messageID set
|
||||||
|
didSet {
|
||||||
|
// Only persist if there are changes
|
||||||
|
guard oldValue != sentReadReceipts else { return }
|
||||||
|
|
||||||
|
// Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
|
||||||
|
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
|
||||||
|
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
|
||||||
|
} else {
|
||||||
|
SecureLogger.log("❌ Failed to encode read receipts for persistence",
|
||||||
|
category: SecureLogger.session, level: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Throttle verification response toasts per peer to avoid spam
|
// Throttle verification response toasts per peer to avoid spam
|
||||||
private var lastVerifyToastAt: [String: Date] = [:]
|
private var lastVerifyToastAt: [String: Date] = [:]
|
||||||
@@ -456,15 +470,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init() {
|
init() {
|
||||||
// Initialize read receipt tracker first
|
// Load persisted read receipts
|
||||||
self.readReceiptTracker = ReadReceiptTracker()
|
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
|
||||||
|
let receipts = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
|
self.sentReadReceipts = Set(receipts)
|
||||||
|
// Successfully loaded read receipts
|
||||||
|
} else {
|
||||||
|
// No persisted read receipts found
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
self.commandProcessor = CommandProcessor()
|
self.commandProcessor = CommandProcessor()
|
||||||
self.privateChatManager = PrivateChatManager(meshService: meshService, readReceiptTracker: readReceiptTracker)
|
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
||||||
self.nostrInbox = NostrInboxService()
|
let nostrTransport = NostrTransport()
|
||||||
self.nostrOutbox = NostrOutboxService(meshService: meshService)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: self.nostrOutbox)
|
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ struct FingerprintView: View {
|
|||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Prefer short mesh ID for session/encryption status
|
// Prefer short mesh ID for session/encryption status
|
||||||
let statusPeerID: String = {
|
let statusPeerID: String = {
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||||
return peerID
|
return peerID
|
||||||
}()
|
}()
|
||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||||
|
|||||||
Reference in New Issue
Block a user