Compare commits

..
Author SHA1 Message Date
jack 4d4c596a17 UI: diversify peer colors; smarter geo notifications; 21m live location
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.
2025-08-26 18:39:48 +02:00
17 changed files with 203 additions and 624 deletions
+16 -66
View File
@@ -2,7 +2,7 @@
## bitchat
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
+4 -22
View File
@@ -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 */,
@@ -754,11 +742,8 @@
isa = PBXSourcesBuildPhase;
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 */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -816,11 +801,8 @@
isa = PBXSourcesBuildPhase;
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 */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard PeerIDResolver.isShortID(peerID) else { return [] }
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
+1 -11
View File
@@ -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
}
}
+4 -3
View File
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -664,7 +664,8 @@ final class BLEService: NSObject {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
return publicKey.sha256Fingerprint()
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
}
return nil
}
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes)
guard PeerIDResolver.isShortID(peerID) else { return nil }
guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
+4 -3
View File
@@ -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
-90
View File
@@ -1,90 +0,0 @@
//
// NostrInboxService.swift
// bitchat
//
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrInboxService {
private var processedIDs: Set<String> = []
private var order: [String] = []
private let cap: Int
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
self.cap = capacity
}
private func track(_ id: String) -> Bool {
if processedIDs.contains(id) { return false }
processedIDs.insert(id)
order.append(id)
if order.count > cap {
let overflow = order.count - cap
for _ in 0..<overflow {
if let oldest = order.first {
order.removeFirst()
processedIDs.remove(oldest)
}
}
}
return true
}
func reset() {
processedIDs.removeAll()
order.removeAll()
}
// Subscribe to public geohash ephemeral events (kind 20000)
@discardableResult
func subscribeGeohashEphemeral(
geohash: String,
lookbackSeconds: TimeInterval,
limit: Int,
relayCount: Int,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "geo-\(geohash)"
let filter = NostrFilter.geohashEphemeral(
geohash,
since: Date().addingTimeInterval(-lookbackSeconds),
limit: limit
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
// Subscribe to gift wraps (DMs) for a given pubkey
@discardableResult
func subscribeGiftWrapsFor(
pubkeyHex: String,
lookbackSeconds: TimeInterval,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
guard let self = self else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
func unsubscribe(id: String) {
NostrRelayManager.shared.unsubscribe(id: id)
}
}
-50
View File
@@ -1,50 +0,0 @@
//
// NostrOutboxService.swift
// bitchat
//
// Centralized Nostr outbox for sending PMs and ACKs.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrOutboxService {
private let transport: NostrTransport
init(meshService: Transport) {
let t = NostrTransport()
t.senderPeerID = meshService.myPeerID
self.transport = t
}
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
}
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
// MARK: - Generic outbox via favorites mapping
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
transport.sendReadReceipt(receipt, to: peerID)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
transport.sendDeliveryAck(for: messageID, to: peerID)
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey {
return npub
}
if PeerIDResolver.isShortID(peerID),
if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
+6 -7
View File
@@ -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(
-77
View File
@@ -1,77 +0,0 @@
//
// ReadReceiptTracker.swift
// bitchat
//
// Centralized tracker for sent read receipts with simple persistence.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class ReadReceiptTracker {
private let defaults: UserDefaults
private let key = "sentReadReceipts"
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
private var set: Set<String> = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: key),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.set = Set(arr)
}
}
func contains(_ id: String) -> Bool {
queue.sync { set.contains(id) }
}
func insert(_ id: String) {
queue.async(flags: .barrier) {
if self.set.insert(id).inserted { self.persist() }
}
}
func insert<S: Sequence>(_ ids: S) where S.Element == String {
queue.async(flags: .barrier) {
var changed = false
for id in ids { changed = self.set.insert(id).inserted || changed }
if changed { self.persist() }
}
}
func remove(_ id: String) {
queue.async(flags: .barrier) {
if self.set.remove(id) != nil { self.persist() }
}
}
func removeAll() {
queue.async(flags: .barrier) {
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
}
}
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
func prune(toAllowedIDs allowed: Set<String>) {
queue.async(flags: .barrier) {
let newSet = self.set.intersection(allowed)
if newSet.count != self.set.count { self.set = newSet; self.persist() }
}
}
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
func snapshot() -> Set<String> { queue.sync { set } }
private func persist() {
let arr = Array(set)
if let data = try? JSONEncoder().encode(arr) {
defaults.set(data, forKey: key)
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
+7 -1
View File
@@ -412,4 +412,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Helper Extensions
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
+148 -282
View File
@@ -161,7 +161,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}()
let full = (nostrKeyMapping[spid] ?? bare).lowercased()
return "nostr:" + full
} else if PeerIDResolver.isShortID(spid), let full = getNoiseKeyForShortID(spid)?.lowercased() {
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
return "noise:" + full
} else {
return "mesh:" + spid.lowercased()
@@ -245,7 +245,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private let commandProcessor: CommandProcessor
private let messageRouter: MessageRouter
private let privateChatManager: PrivateChatManager
private let readReceiptTracker: ReadReceiptTracker
private let unifiedPeerService: UnifiedPeerService
private let autocompleteService: AutocompleteService
@@ -309,7 +308,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func getNoiseKeyForShortID(_ shortPeerID: String) -> String? {
if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped }
// Fallback: derive from active Noise session if available
if PeerIDResolver.isShortID(shortPeerID),
if shortPeerID.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let stable = key.hexEncodedString()
shortIDToNoiseKey[shortPeerID] = stable
@@ -348,7 +347,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var meshService: Transport = BLEService()
private var nostrRelayManager: NostrRelayManager?
// PeerManager replaced by UnifiedPeerService
// NostrInboxService handles event de-duplication
private var processedNostrEvents = Set<String>() // Simple deduplication
private var processedNostrEventOrder: [String] = []
private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap
private let userDefaults = UserDefaults.standard
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
@@ -357,8 +358,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private var geoDmSubscriptionID: String? = nil
private var currentGeohash: String? = nil
private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
private var nostrInbox: NostrInboxService
private var nostrOutbox: NostrOutboxService
// MARK: - Caches
@@ -436,7 +435,22 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@Published private(set) var isBatchingPublic: Bool = false
private let lateInsertThreshold: TimeInterval = TransportConfig.uiLateInsertThreshold
// Read receipts are centralized in ReadReceiptTracker
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
private var sentReadReceipts: Set<String> = [] { // messageID set
didSet {
// Only persist if there are changes
guard oldValue != sentReadReceipts else { return }
// Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
// Throttle verification response toasts per peer to avoid spam
private var lastVerifyToastAt: [String: Date] = [:]
@@ -456,15 +470,21 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
init() {
// Initialize read receipt tracker first
self.readReceiptTracker = ReadReceiptTracker()
// Load persisted read receipts
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
let receipts = try? JSONDecoder().decode([String].self, from: data) {
self.sentReadReceipts = Set(receipts)
// Successfully loaded read receipts
} else {
// No persisted read receipts found
}
// Initialize services
self.commandProcessor = CommandProcessor()
self.privateChatManager = PrivateChatManager(meshService: meshService, readReceiptTracker: readReceiptTracker)
self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
self.nostrInbox = NostrInboxService()
self.nostrOutbox = NostrOutboxService(meshService: meshService)
self.messageRouter = MessageRouter(mesh: meshService, nostr: self.nostrOutbox)
let nostrTransport = NostrTransport()
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter
self.autocompleteService = AutocompleteService()
@@ -693,198 +713,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
#endif
}
// MARK: - Dependency-injected initializer (for testing and composition)
@MainActor
init(meshService: Transport,
commandProcessor: CommandProcessor,
privateChatManager: PrivateChatManager,
unifiedPeerService: UnifiedPeerService,
messageRouter: MessageRouter,
readReceiptTracker: ReadReceiptTracker = ReadReceiptTracker()) {
// Assign dependencies
self.meshService = meshService
self.commandProcessor = commandProcessor
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.messageRouter = messageRouter
self.readReceiptTracker = readReceiptTracker
self.autocompleteService = AutocompleteService()
self.nostrInbox = NostrInboxService()
self.nostrOutbox = NostrOutboxService(meshService: meshService)
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.privateChatManager.messageRouter = self.messageRouter
self.commandProcessor.meshService = meshService
// Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
loadNickname()
loadVerifiedFingerprints()
meshService.delegate = self
// Log fingerprint after a delay to ensure encryption service is ready
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
if let self = self {
_ = self.getMyFingerprint()
}
}
// Set nickname before starting services
meshService.setNickname(nickname)
// Start mesh service immediately
meshService.startServices()
// Initialize Nostr services
Task { @MainActor in
nostrRelayManager = NostrRelayManager.shared
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
nostrRelayManager?.connect()
// Small delay to ensure read receipts are fully loaded
try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs)
// Set up Nostr message handling directly
setupNostrMessageHandling()
// Attempt to flush any queued outbox after Nostr comes online
messageRouter.flushAllOutbox()
// End startup phase after 2 seconds
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000))
self.isStartupPhase = false
}
// Bind unified peer service's peer list to our published property
let cancellable = unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
// Update peers on main thread for UI consistency
guard let self = self else { return }
Task { @MainActor in
self.allPeers = peers
// Build a quick index for getPeer(by:)
var uniquePeers: [String: BitchatPeer] = [:]
for peer in peers {
if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer
}
}
self.peerIndex = uniquePeers
if self.selectedPrivateChatFingerprint != nil {
self.updatePrivateChatPeerIfNeeded()
}
}
}
self.cancellables.insert(cancellable)
// Resubscribe geohash on relay reconnect
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] connected in
guard let self = self else { return }
if connected {
Task { @MainActor in
self.resubscribeCurrentGeohash()
}
}
}
.store(in: &self.cancellables)
}
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self = self else { return }
Task { @MainActor in
self.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
// Initialize with current selection
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Request notification permission
NotificationService.shared.requestAuthorization()
// Listen for favorite status changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteStatusChanged),
name: .favoriteStatusChanged,
object: nil
)
// Listen for peer status updates to refresh UI
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePeerStatusUpdate),
name: Notification.Name("peerStatusUpdated"),
object: nil
)
#if os(macOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: NSApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: NSApplication.willTerminateNotification,
object: nil
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(userDidTakeScreenshot),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
#endif
}
// MARK: - Deinitialization
deinit {
@@ -904,15 +732,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
startGeoParticipantsTimer()
// Unsubscribe + resubscribe
NostrRelayManager.shared.unsubscribe(id: subID)
nostrInbox.subscribeGeohashEphemeral(
geohash: ch.geohash,
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
limit: TransportConfig.nostrGeohashInitialLimit,
relayCount: TransportConfig.nostrGeoRelayCount,
subID: subID
) { [weak self] event in
let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: ch.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
if self.processedNostrEvents.contains(event.id) { return }
self.processedNostrEvents.insert(event.id)
if let gh = self.currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
@@ -969,12 +802,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
nostrInbox.subscribeGiftWrapsFor(
pubkeyHex: id.publicKeyHex,
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
subID: dmSub
) { [weak self] giftWrap in
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return }
if self.processedNostrEvents.contains(giftWrap.id) { return }
self.recordProcessedEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return }
guard content.hasPrefix("bitchat1:") else { return }
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
@@ -990,7 +822,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let messageId = pm.messageID
// Send delivery ACK immediately (once per message ID)
if !self.sentGeoDeliveryAcks.contains(messageId) {
self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
let nt = NostrTransport()
nt.senderPeerID = self.meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId)
}
// Dedup storage
@@ -999,7 +833,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let senderName = self.displayNameForNostrPubkey(senderPubkey)
let isViewing = (self.selectedPrivateChatPeer == convKey)
// pared back: omit view-state log
let wasReadBefore = self.readReceiptTracker.contains(messageId)
let wasReadBefore = self.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage(
@@ -1026,8 +860,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing {
// pared back: omit pre-send READ log
if !wasReadBefore {
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
self.readReceiptTracker.insert(messageId)
let nt = NostrTransport()
nt.senderPeerID = self.meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId)
}
} else {
// Notify for truly unread and recent messages when not viewing
@@ -1157,7 +993,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Ephemeral peer IDs are 8 bytes = 16 hex characters
// Noise public keys are 32 bytes = 64 hex characters
if PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) {
// This is a stable Noise key hex (used in private chats)
// Find the ephemeral peer ID for this Noise key
let ephemeralPeerID = unifiedPeerService.peers.first { peer in
@@ -1220,7 +1056,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func isFavorite(peerID: String) -> Bool {
// Distinguish between ephemeral peer IDs (16 hex chars) and Noise public keys (64 hex chars)
if PeerIDResolver.isNoiseKeyHex(peerID), let noisePublicKey = Data(hexString: peerID) {
if peerID.count == 64, let noisePublicKey = Data(hexString: peerID) {
// This is a Noise public key
if let status = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) {
return status.isFavorite
@@ -1507,7 +1343,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
publicBuffer.removeAll(keepingCapacity: false)
activeChannel = channel
// Reset deduplication set and optionally hydrate timeline for mesh
nostrInbox.reset()
processedNostrEvents.removeAll()
processedNostrEventOrder.removeAll()
switch channel {
case .mesh:
messages = meshTimeline
@@ -1551,16 +1388,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let subID = "geo-\(ch.geohash)"
geoSubscriptionID = subID
startGeoParticipantsTimer()
nostrInbox.subscribeGeohashEphemeral(
geohash: ch.geohash,
lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds,
limit: TransportConfig.nostrGeohashInitialLimit,
relayCount: TransportConfig.nostrGeoRelayCount,
subID: subID
) { [weak self] event in
let filter = NostrFilter.geohashEphemeral(
ch.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
// Only handle ephemeral kind 20000 with matching tag
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Deduplicate
if self.processedNostrEvents.contains(event.id) { return }
self.recordProcessedEvent(event.id)
// Log incoming tags for diagnostics
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)",
@@ -1637,8 +1477,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// pared back logging: subscribe debug only
SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)",
category: SecureLogger.session, level: .debug)
nostrInbox.subscribeGiftWrapsFor(pubkeyHex: id.publicKeyHex, lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds, subID: dmSub) { [weak self] giftWrap in
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
guard let self = self else { return }
// Dedup basic
if self.processedNostrEvents.contains(giftWrap.id) { return }
self.recordProcessedEvent(giftWrap.id)
// Decrypt with per-geohash identity
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else {
SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))",
@@ -1663,7 +1507,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .info)
// Send delivery ACK immediately (even if duplicate), once per messageID
if !self.sentGeoDeliveryAcks.contains(messageId) {
self.nostrOutbox.sendGeohashDeliveredAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = self.meshService.myPeerID
// pared back: omit pre-send log
nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id)
self.sentGeoDeliveryAcks.insert(messageId)
}
// Duplicate check
@@ -1671,7 +1518,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } }
let senderName = self.displayNameForNostrPubkey(senderPubkey)
let isViewing = (self.selectedPrivateChatPeer == convKey)
let wasReadBefore = self.readReceiptTracker.contains(messageId)
let wasReadBefore = self.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage(
@@ -1695,8 +1542,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if isViewing {
// pared back: omit pre-send READ log
if !wasReadBefore {
self.nostrOutbox.sendGeohashReadAck(messageID: messageId, toRecipientHex: senderPubkey, from: id)
self.readReceiptTracker.insert(messageId)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = self.meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
self.sentReadReceipts.insert(messageId)
}
} else {
// pared back: omit defer READ log
@@ -1900,7 +1749,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
//
for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) {
nostrInbox.unsubscribe(id: subID)
NostrRelayManager.shared.unsubscribe(id: subID)
geoSamplingSubs.removeValue(forKey: subID)
}
@@ -1908,13 +1757,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for gh in toAdd {
let subID = "geo-sample-\(gh)"
geoSamplingSubs[subID] = gh
nostrInbox.subscribeGeohashEphemeral(
geohash: gh,
lookbackSeconds: TransportConfig.nostrGeohashSampleLookbackSeconds,
limit: TransportConfig.nostrGeohashSampleLimit,
relayCount: TransportConfig.nostrGeoRelayCount,
subID: subID
) { [weak self] event in
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Update participants for this specific geohash
@@ -1990,7 +1839,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Dedup helper with small memory cap
private func recordProcessedEvent(_ id: String) {}
private func recordProcessedEvent(_ id: String) {
processedNostrEvents.insert(id)
processedNostrEventOrder.append(id)
if processedNostrEventOrder.count > maxProcessedNostrEvents {
let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents
for _ in 0..<overflow {
if let old = processedNostrEventOrder.first {
processedNostrEventOrder.removeFirst()
processedNostrEvents.remove(old)
}
}
}
}
/// Sends an encrypted private message to a specific peer.
/// - Parameters:
@@ -2054,7 +1915,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
SecureLogger.log("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: SecureLogger.session, level: .debug)
self.nostrOutbox.sendGeohashPM(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
@@ -2279,7 +2142,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Never mark old messages as unread during consolidation
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !readReceiptTracker.contains(message.id) {
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
hasActualUnreadMessages = true
}
}
@@ -2400,7 +2263,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Delegate to private chat manager but add already-acked messages first
// This prevents duplicate read receipts
// IMPORTANT: Only add messages WE sent to readReceiptTracker, not messages we received
// IMPORTANT: Only add messages WE sent to sentReadReceipts, not messages we received
if let messages = privateChats[peerID] {
for message in messages {
// Only track read receipts for messages WE sent (not received messages)
@@ -2409,7 +2272,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if let status = message.deliveryStatus {
switch status {
case .read, .delivered:
readReceiptTracker.insert(message.id)
sentReadReceipts.insert(message.id)
privateChatManager.sentReadReceipts.insert(message.id)
default:
break
}
@@ -2763,11 +2627,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !readReceiptTracker.contains(message.id) {
if !sentReadReceipts.contains(message.id) {
SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: SecureLogger.session, level: .debug)
self.nostrOutbox.sendGeohashReadAck(messageID: message.id, toRecipientHex: recipientHex, from: id)
readReceiptTracker.insert(message.id)
let nostrTransport = NostrTransport()
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
sentReadReceipts.insert(message.id)
}
}
return
@@ -2805,12 +2671,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Check both the ephemeral peer ID and stable Noise key as sender
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
// Skip if we already sent an ACK for this message
if !readReceiptTracker.contains(message.id) {
if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID
let recipPeer = (Data(hexString: peerID) != nil) ? peerID : (unifiedPeerService.getPeer(by: peerID)?.noisePublicKey.hexEncodedString() ?? peerID)
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
messageRouter.sendReadReceipt(receipt, to: recipPeer)
readReceiptTracker.insert(message.id)
sentReadReceipts.insert(message.id)
}
}
}
@@ -2938,7 +2804,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
selectedPrivateChatFingerprint = nil
// Clear read receipt tracking
readReceiptTracker.removeAll()
sentReadReceipts.removeAll()
processedNostrAcks.removeAll()
// Clear all caches
@@ -3737,7 +3603,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
} else if PeerIDResolver.isShortID(spid) {
} else if spid.count == 16 {
// Mesh short ID
return getPeerPaletteColor(for: spid, isDark: isDark)
} else {
@@ -4123,7 +3989,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Check if this might already be a nickname (not a hex peer ID)
// Peer IDs are hex strings, so they only contain 0-9 and a-f
let isHexID = PeerIDResolver.isShortID(peerID) || PeerIDResolver.isNoiseKeyHex(peerID)
let isHexID = peerID.allSatisfy { $0.isHexDigit }
if !isHexID {
// If it's already a nickname, just return it
return peerID
@@ -4571,10 +4437,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
for message in messages {
// Remove read receipts for messages FROM this peer (not TO this peer)
if message.senderPeerID == peerID {
// Prune single ID from tracker if needed
// Note: ReadReceiptTracker persists asynchronously
// Remove only if we want to allow resending READ later
// For now keep as-is; leave this as a no-op
sentReadReceipts.remove(message.id)
}
}
}
@@ -4677,7 +4540,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// Don't remove stable Noise key hexes (64 char hex strings) that have messages
// These are used for Nostr messages when peer is offline
if PeerIDResolver.isNoiseKeyHex(staleID) {
if staleID.count == 64, staleID.allSatisfy({ $0.isHexDigit }) {
if let messages = privateChats[staleID], !messages.isEmpty {
// Keep this ID - it's a stable key with messages
continue
@@ -4716,10 +4579,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Remove receipts for messages we no longer have
let before = readReceiptTracker.snapshot()
readReceiptTracker.prune(toAllowedIDs: validMessageIDs)
let after = readReceiptTracker.snapshot()
let removedCount = before.count - after.count
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - sentReadReceipts.count
if removedCount > 0 {
SecureLogger.log("🧹 Cleaned up \(removedCount) old read receipts",
category: SecureLogger.session, level: .debug)
@@ -4905,11 +4768,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
category: SecureLogger.session, level: .debug)
// Subscribe to Nostr messages
let _ = nostrInbox.subscribeGiftWrapsFor(
pubkeyHex: currentIdentity.publicKeyHex,
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
subID: "chat-messages"
) { [weak self] event in
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
)
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor in
self?.handleNostrMessage(event)
}
@@ -4918,7 +4782,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func handleNostrMessage(_ giftWrap: NostrEvent) {
// De-duplication handled by NostrInboxService
// Simple deduplication
if processedNostrEvents.contains(giftWrap.id) { return }
processedNostrEvents.insert(giftWrap.id)
// Client-side filtering: ignore messages older than 24 hours
// Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes
@@ -5003,7 +4869,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
if messageExistsLocally { return }
let wasReadBefore = readReceiptTracker.contains(messageId)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Is viewing?
var isViewingThisChat = false
@@ -5080,17 +4946,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id {
unreadPrivateMessages.remove(ephemeralPeerID)
}
if !readReceiptTracker.contains(messageId) {
if !sentReadReceipts.contains(messageId) {
if let key = actualSenderNoiseKey {
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
readReceiptTracker.insert(messageId)
sentReadReceipts.insert(messageId)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport()
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id)
readReceiptTracker.insert(messageId)
sentReadReceipts.insert(messageId)
SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))", category: SecureLogger.session, level: .debug)
}
}
@@ -5241,7 +5107,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var noiseKey: Data? = nil
// First try as hex-encoded Noise key (64 chars)
if PeerIDResolver.isNoiseKeyHex(peerID) {
if peerID.count == 64 {
noiseKey = Data(hexString: peerID)
}
@@ -5291,7 +5157,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Check if we've read this message before (in a previous session)
let wasReadBefore = readReceiptTracker.contains(messageId)
let wasReadBefore = sentReadReceipts.contains(messageId)
// Try to find sender by checking all known peers for nickname matches
// This is a fallback when we receive Nostr messages from someone not in favorites
@@ -5639,7 +5505,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
// IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer
// This ensures Nostr messages appear in BLE chats
if PeerIDResolver.isShortID(peerID) { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if peerID.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars)
if let peer = unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = peer.noisePublicKey.hexEncodedString()
@@ -5710,7 +5576,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
if selectedPrivateChatPeer != peerID {
unreadPrivateMessages.insert(peerID)
// Avoid notifying for messages that have been marked read already (resubscribe/dup cases)
if !readReceiptTracker.contains(message.id) {
if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
@@ -5726,7 +5592,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
cleanupStaleUnreadPeerIDs()
// Send read receipt if needed
if !readReceiptTracker.contains(message.id) {
if !sentReadReceipts.contains(message.id) {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService.myPeerID,
@@ -5746,7 +5612,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
self.sendReadReceipt(receipt, to: recipientID, originalTransport: originalTransport)
}
readReceiptTracker.insert(message.id)
sentReadReceipts.insert(message.id)
}
// Mark other messages as read
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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) {