mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 21:45:22 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06791ce218 | ||
|
|
95af23a8f6 | ||
|
|
b919b3ff0a | ||
|
|
ae2b247834 | ||
|
|
c6ed3cd665 | ||
|
|
08eceab7cd | ||
|
|
11950a7fe4 | ||
|
|
86bdb1af27 | ||
|
|
744e87f924 | ||
|
|
0d1450df4d | ||
|
|
89175e2065 | ||
|
|
bc383cb02f | ||
|
|
1913719662 | ||
|
|
fc690fc0ac | ||
|
|
35e4ad8914 | ||
|
|
f251c5333c | ||
|
|
92a0204368 | ||
|
|
acfce858ec | ||
|
|
dc9b9e996d |
@@ -2,7 +2,7 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers. It's the side-groupchat.
|
||||
A decentralized peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers. It's the side-groupchat.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
@@ -11,99 +11,50 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Transport Architecture**: Bluetooth mesh for offline + Nostr protocol for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over global Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org)
|
||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||
- **Universal App**: Native support for iOS and macOS
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Performance Optimizations**: LZ4 message compression, adaptive battery modes, and optimized networking
|
||||
|
||||
|
||||
## [Technical Architecture](https://deepwiki.com/permissionlesstech/bitchat)
|
||||
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
- **Local Communication**: Direct peer-to-peer within Bluetooth range
|
||||
- **Multi-hop Relay**: Messages route through nearby devices (max 7 hops)
|
||||
- **No Internet Required**: Works completely offline in disaster scenarios
|
||||
- **Noise Protocol Encryption**: End-to-end encryption with forward secrecy
|
||||
- **Binary Protocol**: Compact packet format optimized for Bluetooth LE constraints
|
||||
- **Automatic Discovery**: Peer discovery and connection management
|
||||
- **Adaptive Power**: Battery-optimized duty cycling
|
||||
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
- **Global Reach**: Connect with users worldwide via internet relays
|
||||
- **Location Channels**: Geographic chat rooms using geohash coordinates
|
||||
- **290+ Relay Network**: Distributed across the globe for reliability
|
||||
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
|
||||
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
|
||||
|
||||
### Channel Types
|
||||
|
||||
#### `mesh #bluetooth`
|
||||
|
||||
- **Transport**: Bluetooth Low Energy mesh network
|
||||
- **Scope**: Local devices within multi-hop range
|
||||
- **Internet**: Not required
|
||||
- **Use Case**: Offline communication, protests, disasters, remote areas
|
||||
|
||||
#### Location Channels (`block #dr5rsj7`, `neighborhood #dr5rs`, `country #dr`)
|
||||
|
||||
- **Transport**: Nostr protocol over internet
|
||||
- **Scope**: Geographic areas defined by geohash precision
|
||||
- `block` (7 chars): City block level
|
||||
- `neighborhood` (6 chars): District/neighborhood
|
||||
- `city` (5 chars): City level
|
||||
- `province` (4 chars): State/province
|
||||
- `region` (2 chars): Country/large region
|
||||
- **Internet**: Required (connects to Nostr relays)
|
||||
- **Use Case**: Location-based community chat, local events, regional discussions
|
||||
|
||||
### Direct Message Routing
|
||||
|
||||
Private messages use **intelligent transport selection**:
|
||||
|
||||
1. **Bluetooth First** (preferred when available)
|
||||
|
||||
- Direct connection with established Noise session
|
||||
- Fastest and most private option
|
||||
|
||||
2. **Nostr Fallback** (when Bluetooth unavailable)
|
||||
|
||||
- Uses recipient's Nostr public key
|
||||
- NIP-17 gift-wrapping for privacy
|
||||
- Routes through global relay network
|
||||
|
||||
3. **Smart Queuing** (when neither available)
|
||||
- Messages queued until transport becomes available
|
||||
- Automatic delivery when connection established
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
|
||||
For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.md).
|
||||
|
||||
|
||||
## Setup
|
||||
|
||||
### Option 1: Using XcodeGen (Recommended)
|
||||
|
||||
1. Install XcodeGen if you haven't already:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
2. Generate the Xcode project:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
xcodegen generate
|
||||
@@ -117,7 +68,6 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
### Option 2: Using Swift Package Manager
|
||||
|
||||
1. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
cd bitchat
|
||||
open Package.swift
|
||||
|
||||
@@ -56,12 +56,6 @@
|
||||
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
|
||||
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
|
||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
|
||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
|
||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
|
||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
|
||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
|
||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
|
||||
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
|
||||
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
|
||||
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
|
||||
@@ -219,9 +213,6 @@
|
||||
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
|
||||
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
|
||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
|
||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
|
||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
|
||||
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
|
||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
|
||||
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
|
||||
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
|
||||
@@ -529,9 +520,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
|
||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
|
||||
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||
@@ -755,9 +743,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
||||
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
|
||||
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
|
||||
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
|
||||
@@ -817,9 +802,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
|
||||
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
|
||||
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
|
||||
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
|
||||
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
|
||||
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
|
||||
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
|
||||
|
||||
@@ -173,14 +173,6 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle deeplink (e.g., geohash activity)
|
||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
||||
#else
|
||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
||||
#endif
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
}
|
||||
@@ -200,15 +192,6 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suppress geohash activity notification if we're already in that geohash channel
|
||||
if identifier.hasPrefix("geo-activity-"),
|
||||
let deep = userInfo["deeplink"] as? String,
|
||||
let gh = deep.components(separatedBy: "/").last {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification in all other cases
|
||||
completionHandler([.banner, .sound])
|
||||
|
||||
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard PeerIDResolver.isShortID(peerID) else { return [] }
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Hex Encoding/Decoding
|
||||
|
||||
@@ -36,16 +35,6 @@ extension Data {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fingerprint Helpers
|
||||
|
||||
extension Data {
|
||||
/// SHA-256 over data, hex-encoded (lowercase)
|
||||
func sha256Fingerprint() -> String {
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Binary Encoding Utilities
|
||||
|
||||
extension Data {
|
||||
@@ -231,3 +220,4 @@ extension Data {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
|
||||
func isPeerConnected(_ peerID: String) -> Bool {
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
let shortID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||
}
|
||||
return peerID
|
||||
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
|
||||
func isPeerReachable(_ peerID: String) -> Bool {
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
let shortID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
|
||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||
}
|
||||
return peerID
|
||||
@@ -664,7 +664,8 @@ final class BLEService: NSObject {
|
||||
return collectionsQueue.sync {
|
||||
if let publicKey = peers[peerID]?.noisePublicKey {
|
||||
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
|
||||
return publicKey.sha256Fingerprint()
|
||||
let hash = SHA256.hash(data: publicKey)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard PeerIDResolver.isShortID(peerID) else { return nil }
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
|
||||
@@ -92,30 +92,23 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Stop any previous polling timer
|
||||
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.requestOneShotLocation()
|
||||
}
|
||||
// Kick off immediately
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
/// Stop periodic refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
|
||||
@@ -4,12 +4,13 @@ import Foundation
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrOutboxService
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrOutboxService) {
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -45,7 +46,7 @@ final class MessageRouter {
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
|
||||
@@ -548,7 +548,8 @@ class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||
return publicKey.rawRepresentation.sha256Fingerprint()
|
||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Session Maintenance
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// NostrInboxService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NostrInboxService {
|
||||
private var processedIDs: Set<String> = []
|
||||
private var order: [String] = []
|
||||
private let cap: Int
|
||||
|
||||
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
|
||||
self.cap = capacity
|
||||
}
|
||||
|
||||
private func track(_ id: String) -> Bool {
|
||||
if processedIDs.contains(id) { return false }
|
||||
processedIDs.insert(id)
|
||||
order.append(id)
|
||||
if order.count > cap {
|
||||
let overflow = order.count - cap
|
||||
for _ in 0..<overflow {
|
||||
if let oldest = order.first {
|
||||
order.removeFirst()
|
||||
processedIDs.remove(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reset() {
|
||||
processedIDs.removeAll()
|
||||
order.removeAll()
|
||||
}
|
||||
|
||||
// Subscribe to public geohash ephemeral events (kind 20000)
|
||||
@discardableResult
|
||||
func subscribeGeohashEphemeral(
|
||||
geohash: String,
|
||||
lookbackSeconds: TimeInterval,
|
||||
limit: Int,
|
||||
relayCount: Int,
|
||||
subID: String? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void
|
||||
) -> String {
|
||||
let id = subID ?? "geo-\(geohash)"
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
geohash,
|
||||
since: Date().addingTimeInterval(-lookbackSeconds),
|
||||
limit: limit
|
||||
)
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
guard self.track(event.id) else { return }
|
||||
handler(event)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Subscribe to gift wraps (DMs) for a given pubkey
|
||||
@discardableResult
|
||||
func subscribeGiftWrapsFor(
|
||||
pubkeyHex: String,
|
||||
lookbackSeconds: TimeInterval,
|
||||
subID: String? = nil,
|
||||
handler: @escaping (NostrEvent) -> Void
|
||||
) -> String {
|
||||
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
|
||||
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard self.track(event.id) else { return }
|
||||
handler(event)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func unsubscribe(id: String) {
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// NostrOutboxService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Centralized Nostr outbox for sending PMs and ACKs.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NostrOutboxService {
|
||||
private let transport: NostrTransport
|
||||
|
||||
init(meshService: Transport) {
|
||||
let t = NostrTransport()
|
||||
t.senderPeerID = meshService.myPeerID
|
||||
self.transport = t
|
||||
}
|
||||
|
||||
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
|
||||
}
|
||||
|
||||
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
||||
}
|
||||
|
||||
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
||||
}
|
||||
|
||||
// MARK: - Generic outbox via favorites mapping
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
transport.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
transport.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
}
|
||||
if PeerIDResolver.isShortID(peerID),
|
||||
if peerID.count == 16,
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||
let npub = fav.peerNostrPublicKey {
|
||||
return npub
|
||||
|
||||
@@ -90,15 +90,6 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Geohash public chat notification with deep link to a specific geohash
|
||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||
let title = "\(titlePrefix)\(geohash)"
|
||||
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
|
||||
let deeplink = "bitchat://geohash/\(geohash)"
|
||||
let userInfo: [String: Any] = ["deeplink": deeplink]
|
||||
sendLocalNotification(title: title, body: bodyPreview, identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
|
||||
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
|
||||
@Published var unreadMessages: Set<String> = []
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
private var readReceiptTracker: ReadReceiptTracker?
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
weak var meshService: Transport?
|
||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||
weak var messageRouter: MessageRouter?
|
||||
|
||||
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
self.readReceiptTracker = readReceiptTracker
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
@@ -122,7 +121,7 @@ class PrivateChatManager: ObservableObject {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||
if !(readReceiptTracker?.contains(message.id) ?? false) {
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
@@ -161,7 +160,7 @@ class PrivateChatManager: ObservableObject {
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
if let messages = privateChats[peerID] {
|
||||
for message in messages {
|
||||
if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
|
||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
guard !(readReceiptTracker?.contains(message.id) ?? false),
|
||||
guard !sentReadReceipts.contains(message.id),
|
||||
let senderPeerID = message.senderPeerID else {
|
||||
return
|
||||
}
|
||||
|
||||
readReceiptTracker?.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
//
|
||||
// ReadReceiptTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// Centralized tracker for sent read receipts with simple persistence.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class ReadReceiptTracker {
|
||||
private let defaults: UserDefaults
|
||||
private let key = "sentReadReceipts"
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
|
||||
private var set: Set<String> = []
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
if let data = defaults.data(forKey: key),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
self.set = Set(arr)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(_ id: String) -> Bool {
|
||||
queue.sync { set.contains(id) }
|
||||
}
|
||||
|
||||
func insert(_ id: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
if self.set.insert(id).inserted { self.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
func insert<S: Sequence>(_ ids: S) where S.Element == String {
|
||||
queue.async(flags: .barrier) {
|
||||
var changed = false
|
||||
for id in ids { changed = self.set.insert(id).inserted || changed }
|
||||
if changed { self.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ id: String) {
|
||||
queue.async(flags: .barrier) {
|
||||
if self.set.remove(id) != nil { self.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
queue.async(flags: .barrier) {
|
||||
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
|
||||
func prune(toAllowedIDs allowed: Set<String>) {
|
||||
queue.async(flags: .barrier) {
|
||||
let newSet = self.set.intersection(allowed)
|
||||
if newSet.count != self.set.count { self.set = newSet; self.persist() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
|
||||
func snapshot() -> Set<String> { queue.sync { set } }
|
||||
|
||||
private func persist() {
|
||||
let arr = Array(set)
|
||||
if let data = try? JSONEncoder().encode(arr) {
|
||||
defaults.set(data, forKey: key)
|
||||
} else {
|
||||
SecureLogger.log("❌ Failed to encode read receipts for persistence",
|
||||
category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,14 +100,8 @@ enum TransportConfig {
|
||||
|
||||
// Location
|
||||
static let locationDistanceFilterMeters: Double = 1000
|
||||
// Live (channel sheet open) distance threshold for meaningful updates
|
||||
static let locationDistanceFilterLiveMeters: Double = 21.0
|
||||
static let locationLiveRefreshInterval: TimeInterval = 5.0
|
||||
|
||||
// Notifications (geohash)
|
||||
static let uiGeoNotifyCooldownSeconds: TimeInterval = 60.0
|
||||
static let uiGeoNotifySnippetMaxLen: Int = 80
|
||||
|
||||
// Nostr geohash
|
||||
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
|
||||
static let nostrGeohashInitialLimit: Int = 200
|
||||
@@ -175,10 +169,6 @@ enum TransportConfig {
|
||||
// UI color tuning
|
||||
static let uiColorHueAvoidanceDelta: Double = 0.05
|
||||
static let uiColorHueOffset: Double = 0.12
|
||||
// Peer list palette
|
||||
static let uiPeerPaletteSlots: Int = 36
|
||||
static let uiPeerPaletteRingBrightnessDeltaLight: Double = 0.07
|
||||
static let uiPeerPaletteRingBrightnessDeltaDark: Double = -0.07
|
||||
|
||||
// UI windowing (infinite scroll)
|
||||
static let uiWindowInitialCountPublic: Int = 300
|
||||
|
||||
@@ -412,4 +412,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
|
||||
extension Data {
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1182,7 +1182,7 @@ struct ContentView: View {
|
||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
||||
let headerPeerID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(privatePeerID) {
|
||||
if privatePeerID.count == 64 {
|
||||
// Map stable Noise key to short ID if we know it (even if not directly connected)
|
||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
|
||||
}
|
||||
@@ -1207,14 +1207,14 @@ struct ContentView: View {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
|
||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||
if PeerIDResolver.isShortID(headerPeerID) {
|
||||
if headerPeerID.count == 16 {
|
||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let id = candidates.first,
|
||||
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
} else if PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) {
|
||||
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
|
||||
let fp = keyData.sha256Fingerprint()
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
@@ -1298,7 +1298,7 @@ struct ContentView: View {
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||
let statusPeerID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short
|
||||
}
|
||||
return headerPeerID
|
||||
|
||||
@@ -43,14 +43,14 @@ struct FingerprintView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
let statusPeerID: String = {
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
|
||||
return peerID
|
||||
}()
|
||||
// Resolve a friendly name
|
||||
let peerNickname: String = {
|
||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
||||
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
|
||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||
let fp = data.sha256Fingerprint()
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
|
||||
|
||||
@@ -83,17 +83,24 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
manager.beginLiveRefresh()
|
||||
// Geohash sampling is now managed by ChatViewModel globally
|
||||
// Begin multi-channel sampling for counts
|
||||
let ghs = manager.availableChannels.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
.onDisappear {
|
||||
manager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
.onChange(of: manager.permissionState) { newValue in
|
||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { _ in }
|
||||
.onChange(of: manager.availableChannels) { newValue in
|
||||
// Keep sampling list in sync with available channels as they refresh live
|
||||
let ghs = newValue.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
@@ -282,10 +289,9 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private func meshCount() -> Int {
|
||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user