mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b363b7062 | ||
|
|
7d672f2d69 | ||
|
|
d35d3f9612 | ||
|
|
9e79b18dcb | ||
|
|
b63a595b04 | ||
|
|
d7b7f1f673 | ||
|
|
7aa3622349 | ||
|
|
96c6fc0c0d | ||
|
|
a7d5b2d7d9 |
@@ -2,7 +2,7 @@
|
||||
|
||||
## bitchat
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
@@ -11,50 +11,99 @@ A decentralized peer-to-peer messaging app that works over Bluetooth mesh networ
|
||||
> [!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)
|
||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||
- **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)
|
||||
|
||||
### 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
|
||||
BitChat uses a **hybrid messaging architecture** with two complementary transport layers:
|
||||
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Adaptive duty cycling for battery optimization
|
||||
### 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
|
||||
|
||||
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
|
||||
@@ -68,6 +117,7 @@ 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
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||
@@ -205,6 +209,8 @@
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
|
||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||
@@ -328,7 +334,6 @@
|
||||
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
|
||||
9F37F9F2C353B58AC809E93B /* Products */,
|
||||
048A4BE52E5CCC5C00162C4A /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -484,6 +489,7 @@
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
@@ -520,6 +526,7 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
@@ -743,14 +750,13 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.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 */,
|
||||
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
|
||||
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
|
||||
|
||||
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
|
||||
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
|
||||
@@ -773,6 +779,7 @@
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||
@@ -803,14 +810,13 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.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 */,
|
||||
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
|
||||
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
|
||||
|
||||
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
|
||||
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
|
||||
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
|
||||
@@ -833,6 +839,7 @@
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||
@@ -869,6 +876,7 @@
|
||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -891,6 +899,7 @@
|
||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -1007,7 +1016,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -1038,7 +1047,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1093,7 +1102,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1125,7 +1134,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1214,7 +1223,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.3.1;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1307,7 +1316,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.2;
|
||||
MARKETING_VERSION = 1.3.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -173,6 +173,14 @@ 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()
|
||||
}
|
||||
@@ -192,6 +200,15 @@ 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])
|
||||
|
||||
@@ -106,6 +106,8 @@ enum HandshakeState {
|
||||
struct CryptographicIdentity: Codable {
|
||||
let fingerprint: String // SHA256 of public key
|
||||
let publicKey: Data // Noise static public key
|
||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||
var signingPublicKey: Data? = nil
|
||||
let firstSeen: Date
|
||||
let lastHandshake: Date?
|
||||
}
|
||||
|
||||
@@ -230,6 +230,91 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key and lastHandshake
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
let updated = CryptographicIdentity(
|
||||
fingerprint: existing.fingerprint,
|
||||
publicKey: existing.publicKey,
|
||||
signingPublicKey: existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = updated
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
// New entry
|
||||
let entry = CryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
publicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now,
|
||||
lastHandshake: now
|
||||
)
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
if let claimed = claimedNickname {
|
||||
var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: claimed,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
// Update claimed nickname if changed
|
||||
if identity.claimedNickname != claimed {
|
||||
identity.claimedNickname = claimed
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
}
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve cryptographic identity by fingerprint
|
||||
func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? {
|
||||
queue.sync { cryptographicIdentities[fingerprint] }
|
||||
}
|
||||
|
||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
||||
}
|
||||
}
|
||||
|
||||
func getAllSocialIdentities() -> [SocialIdentity] {
|
||||
queue.sync {
|
||||
return Array(cache.socialIdentities.values)
|
||||
|
||||
@@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
let nickname: String
|
||||
let lastSeen: Date
|
||||
let isConnected: Bool
|
||||
let isReachable: Bool
|
||||
|
||||
// Favorite-related properties
|
||||
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
||||
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
// Connection state
|
||||
enum ConnectionState {
|
||||
case bluetoothConnected
|
||||
case meshReachable // Seen via mesh recently, not directly connected
|
||||
case nostrAvailable // Mutual favorite, reachable via Nostr
|
||||
case offline // Not connected via any transport
|
||||
}
|
||||
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
var connectionState: ConnectionState {
|
||||
if isConnected {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
return .nostrAvailable
|
||||
@@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
switch connectionState {
|
||||
case .bluetoothConnected:
|
||||
return "📻" // Radio icon for mesh connection
|
||||
case .meshReachable:
|
||||
return "📡" // Antenna for mesh reachable
|
||||
case .nostrAvailable:
|
||||
return "🌐" // Purple globe for Nostr
|
||||
case .offline:
|
||||
@@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable {
|
||||
noisePublicKey: Data,
|
||||
nickname: String,
|
||||
lastSeen: Date = Date(),
|
||||
isConnected: Bool = false
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.lastSeen = lastSeen
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
|
||||
// Load favorite status - will be set later by the manager
|
||||
self.favoriteStatus = nil
|
||||
|
||||
@@ -87,4 +87,28 @@ enum Geohash {
|
||||
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||
return (lat, lon)
|
||||
}
|
||||
|
||||
/// Decodes a geohash into its latitude and longitude bounds.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: (latMin, latMax, lonMin, lonMax)
|
||||
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) {
|
||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||
|
||||
var isEven = true
|
||||
for ch in geohash.lowercased() {
|
||||
guard let cd = base32Map[ch] else { continue }
|
||||
for mask in [16, 8, 4, 2, 1] {
|
||||
if isEven {
|
||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
|
||||
} else {
|
||||
let mid = (latInterval.0 + latInterval.1) / 2
|
||||
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
|
||||
}
|
||||
isEven.toggle()
|
||||
}
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
}
|
||||
|
||||
+423
-116
@@ -102,6 +102,12 @@ final class BLEService: NSObject {
|
||||
|
||||
// Queue for messages pending handshake completion
|
||||
private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:]
|
||||
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
|
||||
private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:]
|
||||
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
|
||||
private var recentAnnounceBySender: [String: BitchatPacket] = [:]
|
||||
private var recentAnnounceOrder: [String] = []
|
||||
private let recentAnnounceBufferCap = 3
|
||||
|
||||
// Queue for notifications that failed due to full queue
|
||||
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
|
||||
@@ -122,6 +128,13 @@ final class BLEService: NSObject {
|
||||
|
||||
// Backpressure-aware write queue per peripheral
|
||||
private var pendingPeripheralWrites: [String: [Data]] = [:]
|
||||
// Debounce duplicate disconnect notifies
|
||||
private var recentDisconnectNotifies: [String: Date] = [:]
|
||||
// Store-and-forward for directed messages when we have no links
|
||||
// Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt)
|
||||
private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
||||
// Debounce for 'reconnected' logs
|
||||
private var lastReconnectLogAt: [String: Date] = [:]
|
||||
|
||||
// MARK: - Maintenance Timer
|
||||
|
||||
@@ -307,6 +320,11 @@ final class BLEService: NSObject {
|
||||
// Send any messages that were queued during handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendPendingMessagesAfterHandshake(for: peerID)
|
||||
self?.sendPendingNoisePayloadsAfterHandshake(for: peerID)
|
||||
}
|
||||
// Proactive presence nudge: announce immediately after handshake
|
||||
self?.messageQueue.async { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +520,27 @@ final class BLEService: NSObject {
|
||||
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
||||
}
|
||||
|
||||
func isPeerReachable(_ peerID: String) -> Bool {
|
||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||
let shortID: String = {
|
||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
||||
}
|
||||
return peerID
|
||||
}()
|
||||
return collectionsQueue.sync {
|
||||
// Must be mesh-attached: at least one live direct link to the mesh
|
||||
let meshAttached = peers.values.contains { $0.isConnected }
|
||||
guard let info = peers[shortID] else { return false }
|
||||
if info.isConnected { return true }
|
||||
guard meshAttached else { return false }
|
||||
// Apply reachability retention window
|
||||
let isVerified = info.isVerifiedNickname
|
||||
let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
return Date().timeIntervalSince(info.lastSeen) <= retention
|
||||
}
|
||||
}
|
||||
|
||||
func peerNickname(peerID: String) -> String? {
|
||||
collectionsQueue.sync {
|
||||
guard let peer = peers[peerID], peer.isConnected else { return nil }
|
||||
@@ -541,43 +580,41 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
// Send encrypted read receipt
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
||||
return
|
||||
}
|
||||
// Create typed payload: [type byte] + [message ID]
|
||||
var payload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||
payload.append(contentsOf: receipt.originalMessageID.utf8)
|
||||
|
||||
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Create read receipt payload: [type byte] + [message ID]
|
||||
var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||
receiptPayload.append(contentsOf: receipt.originalMessageID.utf8)
|
||||
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(receiptPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
|
||||
// If already on messageQueue, call directly
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(packet)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(packet)
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(packet)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
||||
}
|
||||
|
||||
// Read receipt sent
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error)
|
||||
} else {
|
||||
// Queue for after handshake and initiate if needed
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||
}
|
||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
||||
SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,19 +734,26 @@ final class BLEService: NSObject {
|
||||
self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID)
|
||||
} else {
|
||||
// Public broadcast
|
||||
// Public message - logged at relay point for mesh debugging
|
||||
let packet = BitchatPacket(
|
||||
// Create packet with explicit fields so we can sign it
|
||||
let basePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
ttl: self.messageTTL,
|
||||
senderID: self.myPeerID,
|
||||
payload: Data(content.utf8)
|
||||
senderID: Data(hexString: self.myPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(content.utf8),
|
||||
signature: nil,
|
||||
ttl: self.messageTTL
|
||||
)
|
||||
guard let signedPacket = self.noiseService.signPacket(basePacket) else {
|
||||
SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error)
|
||||
return
|
||||
}
|
||||
// Pre-mark our own broadcast as processed to avoid handling relayed self copy
|
||||
let senderHex = packet.senderID.hexEncodedString()
|
||||
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
|
||||
let senderHex = signedPacket.senderID.hexEncodedString()
|
||||
let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)"
|
||||
self.messageDeduplicator.markProcessed(dedupID)
|
||||
// Call synchronously since we're already on background queue
|
||||
self.broadcastPacket(packet)
|
||||
self.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1037,15 +1081,23 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
|
||||
// Special-case announces: do NOT subset to maximize reach for presence
|
||||
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
|
||||
var selectedCentralIDs = Set(allowedCentralIDs)
|
||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
|
||||
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue {
|
||||
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
|
||||
let kc = subsetSizeForFanout(allowedCentralIDs.count)
|
||||
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
|
||||
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
|
||||
}
|
||||
|
||||
// If directed and we currently have no links to forward on, spool for a short window
|
||||
if let only = directedOnlyPeer,
|
||||
selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty,
|
||||
(packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) {
|
||||
spoolDirectedPacket(packet, recipientPeerID: only)
|
||||
}
|
||||
|
||||
// Writes to selected connected peripherals
|
||||
for s in states where s.isConnected {
|
||||
let pid = s.peripheral.identifier.uuidString
|
||||
@@ -1063,6 +1115,57 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Directed store-and-forward
|
||||
private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) {
|
||||
let msgID = makeMessageID(for: packet)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:]
|
||||
if byMsg[msgID] == nil {
|
||||
byMsg[msgID] = (packet: packet, enqueuedAt: Date())
|
||||
self.pendingDirectedRelays[recipientPeerID] = byMsg
|
||||
SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushDirectedSpool() {
|
||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||
let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) {
|
||||
var out: [(String, BitchatPacket)] = []
|
||||
let now = Date()
|
||||
for (recipient, dict) in pendingDirectedRelays {
|
||||
for (_, entry) in dict {
|
||||
if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds {
|
||||
out.append((recipient, entry.packet))
|
||||
}
|
||||
}
|
||||
// Clear recipient bucket; items will be re-spooled if still no links
|
||||
pendingDirectedRelays.removeValue(forKey: recipient)
|
||||
}
|
||||
return out
|
||||
}
|
||||
guard !toSend.isEmpty else { return }
|
||||
for (_, packet) in toSend {
|
||||
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
|
||||
}
|
||||
}
|
||||
|
||||
private func rebroadcastRecentAnnounces() {
|
||||
// Snapshot sender order to preserve ordering and avoid holding locks while sending
|
||||
let packets: [BitchatPacket] = collectionsQueue.sync {
|
||||
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
|
||||
}
|
||||
guard !packets.isEmpty else { return }
|
||||
for (idx, pkt) in packets.enumerated() {
|
||||
// Stagger slightly to avoid bursts
|
||||
let delayMs = idx * 20
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(pkt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
|
||||
// Fire-and-forget: Simple send without complex fallback logic
|
||||
guard peripheral.state == .connected else { return }
|
||||
@@ -1127,7 +1230,8 @@ final class BLEService: NSObject {
|
||||
ttl: packet.ttl
|
||||
)
|
||||
// Pace fragments with small jitter to avoid bursts
|
||||
let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
|
||||
let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs
|
||||
let delayMs = index * perFragMs
|
||||
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
|
||||
self?.broadcastPacket(fragmentPacket)
|
||||
}
|
||||
@@ -1222,10 +1326,14 @@ final class BLEService: NSObject {
|
||||
SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
// Cancel any pending relay for this message (arrived via another neighbor)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
||||
task.cancel()
|
||||
// In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging.
|
||||
// In denser graphs, cancel the pending relay to reduce redundant floods.
|
||||
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
|
||||
if connectedCount > 2 {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
if let task = self?.scheduledRelays.removeValue(forKey: messageID) {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
return // Duplicate ignored
|
||||
@@ -1284,6 +1392,7 @@ final class BLEService: NSObject {
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
@@ -1334,11 +1443,14 @@ final class BLEService: NSObject {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
// Check if we have an actual BLE connection to this peer
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
_ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection
|
||||
let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true
|
||||
|
||||
// Check if this peer is subscribed to us as a central
|
||||
// Note: We can't identify which specific central is which peer without additional mapping
|
||||
_ = !subscribedCentrals.isEmpty // hasCentralSubscription
|
||||
let hasCentralSubscription = centralToPeerID.values.contains(peerID)
|
||||
|
||||
// Direct announces arrive with full TTL (no prior hop)
|
||||
let isDirectAnnounce = (packet.ttl == messageTTL)
|
||||
|
||||
// Check if we already have this peer (might be reconnecting)
|
||||
let existingPeer = peers[peerID]
|
||||
@@ -1376,7 +1488,7 @@ final class BLEService: NSObject {
|
||||
peers[peerID] = PeerInfo(
|
||||
id: existing.id,
|
||||
nickname: announcement.nickname,
|
||||
isConnected: true,
|
||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
@@ -1387,7 +1499,7 @@ final class BLEService: NSObject {
|
||||
peers[peerID] = PeerInfo(
|
||||
id: peerID,
|
||||
nickname: announcement.nickname,
|
||||
isConnected: true,
|
||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
@@ -1395,13 +1507,49 @@ final class BLEService: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
// Log connection status
|
||||
if existingPeer == nil {
|
||||
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||
} else if wasDisconnected {
|
||||
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
|
||||
} else if existingPeer?.nickname != announcement.nickname {
|
||||
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
||||
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
||||
let now = Date()
|
||||
if existingPeer == nil {
|
||||
SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||
} else if wasDisconnected {
|
||||
// Debounce 'reconnected' logs within short window
|
||||
if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds {
|
||||
// Skip duplicate log
|
||||
} else {
|
||||
SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug)
|
||||
lastReconnectLogAt[peerID] = now
|
||||
}
|
||||
} else if existingPeer?.nickname != announcement.nickname {
|
||||
SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist cryptographic identity and signing key for robust offline verification
|
||||
do {
|
||||
// Derive fingerprint from Noise public key
|
||||
let hash = SHA256.hash(data: announcement.noisePublicKey)
|
||||
let fingerprint = hash.map { String(format: "%02x", $0) }.joined()
|
||||
SecureIdentityStateManager.shared.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
}
|
||||
|
||||
// Record this announce for lightweight rebroadcast buffer (exclude self)
|
||||
if peerID != myPeerID {
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.recentAnnounceBySender[peerID] = packet
|
||||
if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) }
|
||||
// Trim to cap, oldest first
|
||||
while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap {
|
||||
let victim = self.recentAnnounceOrder.removeFirst()
|
||||
self.recentAnnounceBySender.removeValue(forKey: victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1412,8 +1560,8 @@ final class BLEService: NSObject {
|
||||
// Get current peer list (after addition)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
// Only notify of connection for new or reconnected peers
|
||||
if isNewPeer || isReconnectedPeer {
|
||||
// Only notify of connection for new or reconnected peers when it is a direct announce
|
||||
if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) {
|
||||
self.delegate?.didConnectToPeer(peerID)
|
||||
}
|
||||
|
||||
@@ -1433,6 +1581,14 @@ final class BLEService: NSObject {
|
||||
// Force send to ensure the peer receives our announce
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
|
||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
||||
if isNewPeer {
|
||||
let delay = Double.random(in: 0.3...0.6)
|
||||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mention parsing moved to ChatViewModel
|
||||
@@ -1441,8 +1597,40 @@ final class BLEService: NSObject {
|
||||
// Ignore self-origin public messages that may be seen again via relay
|
||||
if peerID == myPeerID { return }
|
||||
|
||||
// Enforce: only accept public messages from verified peers we know
|
||||
guard let info = peers[peerID], info.isVerifiedNickname else {
|
||||
var accepted = false
|
||||
var senderNickname: String = ""
|
||||
|
||||
if let info = peers[peerID], info.isVerifiedNickname {
|
||||
// Known verified peer path
|
||||
accepted = true
|
||||
senderNickname = info.nickname
|
||||
// Handle nickname collisions
|
||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.prefix(4))
|
||||
}
|
||||
} else {
|
||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||
// Find candidate identities by peerID prefix (16 hex)
|
||||
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
for candidate in candidates {
|
||||
if let signingKey = candidate.signingPublicKey,
|
||||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
||||
accepted = true
|
||||
// Prefer persisted social petname or claimed nickname
|
||||
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) {
|
||||
senderNickname = social.localPetname ?? social.claimedNickname
|
||||
} else {
|
||||
senderNickname = "anon" + String(peerID.prefix(4))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard accepted else {
|
||||
SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning)
|
||||
return
|
||||
}
|
||||
@@ -1451,16 +1639,16 @@ final class BLEService: NSObject {
|
||||
SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve display nickname; if collisions exist, append short peerID suffix
|
||||
var senderNickname = info.nickname
|
||||
// Treat a collision if another connected peer shares the nickname OR our own nickname matches
|
||||
let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname)
|
||||
if hasCollision {
|
||||
senderNickname += "#" + String(peerID.prefix(4))
|
||||
// Determine if we have a direct link to the sender
|
||||
let hasDirectLink: Bool = collectionsQueue.sync {
|
||||
let perUUID = peerToPeripheralUUID[peerID]
|
||||
let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
return perConnected || hasCentral
|
||||
}
|
||||
|
||||
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
||||
let pathTag = hasDirectLink ? "direct" : "mesh"
|
||||
SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug)
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
notifyUI { [weak self] in
|
||||
@@ -1659,31 +1847,64 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
// Send encrypted delivery ACK
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning)
|
||||
return
|
||||
// Create typed payload: [type byte] + [message ID]
|
||||
var payload = Data([NoisePayloadType.delivered.rawValue])
|
||||
payload.append(contentsOf: messageID.utf8)
|
||||
|
||||
if noiseService.hasEstablishedSession(with: peerID) {
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
|
||||
}
|
||||
} else {
|
||||
// Queue for after handshake and initiate if needed
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||
}
|
||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
||||
SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
// Create ACK payload: [type byte] + [message ID]
|
||||
var ackPayload = Data([NoisePayloadType.delivered.rawValue])
|
||||
ackPayload.append(contentsOf: messageID.utf8)
|
||||
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(ackPayload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
// Delivery ACK sent
|
||||
} catch {
|
||||
SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error)
|
||||
private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) {
|
||||
let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in
|
||||
let list = pendingNoisePayloadsAfterHandshake[peerID] ?? []
|
||||
pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID)
|
||||
return list
|
||||
}
|
||||
guard !payloads.isEmpty else { return }
|
||||
SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
for payload in payloads {
|
||||
do {
|
||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: peerID),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: encrypted,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)",
|
||||
category: SecureLogger.noise, level: .error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1697,6 +1918,18 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window
|
||||
private func notifyPeerDisconnectedDebounced(_ peerID: String) {
|
||||
let now = Date()
|
||||
let last = recentDisconnectNotifies[peerID]
|
||||
if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds {
|
||||
delegate?.didDisconnectFromPeer(peerID)
|
||||
recentDisconnectNotifies[peerID] = now
|
||||
} else {
|
||||
// Suppressed duplicate disconnect notification
|
||||
}
|
||||
}
|
||||
|
||||
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
||||
private func publishFullPeerData() {
|
||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||
@@ -1749,6 +1982,16 @@ final class BLEService: NSObject {
|
||||
if elapsed >= target { sendAnnounce(forceSend: true) }
|
||||
}
|
||||
|
||||
// Activity-driven quick-announce: if we've seen any packet in last 5s and it has
|
||||
// been >=10s since the last announce, send a presence nudge.
|
||||
let recentSeen = collectionsQueue.sync { () -> Bool in
|
||||
let cutoff = now.addingTimeInterval(-5.0)
|
||||
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
|
||||
}
|
||||
if recentSeen && elapsed >= 10.0 {
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
|
||||
// If we have no peers, ensure we're scanning and advertising
|
||||
if peers.isEmpty {
|
||||
// Ensure we're advertising as peripheral
|
||||
@@ -1761,16 +2004,19 @@ final class BLEService: NSObject {
|
||||
updateScanningDutyCycle(connectedCount: connectedCount)
|
||||
updateRSSIThreshold(connectedCount: connectedCount)
|
||||
|
||||
// Every 20 seconds (2 cycles): Check peer connectivity
|
||||
if maintenanceCounter % 2 == 0 {
|
||||
checkPeerConnectivity()
|
||||
}
|
||||
// Check peer connectivity every cycle for snappier UI updates
|
||||
checkPeerConnectivity()
|
||||
|
||||
// Every 30 seconds (3 cycles): Cleanup
|
||||
if maintenanceCounter % 3 == 0 {
|
||||
performCleanup()
|
||||
}
|
||||
|
||||
// Attempt to flush any spooled directed messages periodically (~every 5 seconds)
|
||||
if maintenanceCounter % 2 == 1 {
|
||||
flushDirectedSpool()
|
||||
}
|
||||
|
||||
// No rotating alias: nothing to refresh
|
||||
|
||||
// Reset counter to prevent overflow (every 60 seconds)
|
||||
@@ -1783,28 +2029,39 @@ final class BLEService: NSObject {
|
||||
let now = Date()
|
||||
var disconnectedPeers: [String] = []
|
||||
|
||||
var removedOfflineCount = 0
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
for (peerID, peer) in peers {
|
||||
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||
let age = now.timeIntervalSince(peer.lastSeen)
|
||||
let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||
// Check if we still have an active BLE connection to this peer
|
||||
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
|
||||
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
|
||||
let hasCentralConnection = centralToPeerID.values.contains(peerID)
|
||||
|
||||
// Only remove if we don't have an active BLE connection
|
||||
// If direct link is gone, mark as not connected (retain entry for reachability)
|
||||
if !hasPeripheralConnection && !hasCentralConnection {
|
||||
// Remove the peer completely (they'll be re-added when they reconnect)
|
||||
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
peers.removeValue(forKey: peerID)
|
||||
var updated = peer
|
||||
updated.isConnected = false
|
||||
peers[peerID] = updated
|
||||
disconnectedPeers.append(peerID)
|
||||
}
|
||||
}
|
||||
// Cleanup: remove peers that are not connected and past reachability retention
|
||||
if !peer.isConnected {
|
||||
if age > retention {
|
||||
SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
peers.removeValue(forKey: peerID)
|
||||
removedOfflineCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI if any peers were disconnected
|
||||
if !disconnectedPeers.isEmpty {
|
||||
// Update UI if there were direct disconnections or offline removals
|
||||
if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -1814,6 +2071,8 @@ final class BLEService: NSObject {
|
||||
for peerID in disconnectedPeers {
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
}
|
||||
// Publish snapshots so UnifiedPeerService updates connection/reachability icons
|
||||
self.publishFullPeerData()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
@@ -1857,6 +2116,15 @@ final class BLEService: NSObject {
|
||||
if !self.ingressByMessageID.isEmpty {
|
||||
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||
}
|
||||
// Clean expired directed spooled items
|
||||
if !self.pendingDirectedRelays.isEmpty {
|
||||
var cleaned: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:]
|
||||
for (recipient, dict) in self.pendingDirectedRelays {
|
||||
let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds }
|
||||
if !pruned.isEmpty { cleaned[recipient] = pruned }
|
||||
}
|
||||
self.pendingDirectedRelays = cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1868,7 +2136,13 @@ final class BLEService: NSObject {
|
||||
#else
|
||||
let active = true
|
||||
#endif
|
||||
let shouldDuty = dutyEnabled && active && connectedCount > 0
|
||||
// Force full-time scanning if we have very few neighbors or very recent traffic
|
||||
let hasRecentTraffic: Bool = collectionsQueue.sync {
|
||||
let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds)
|
||||
return recentPacketTimestamps.contains(where: { $0 >= cutoff })
|
||||
}
|
||||
let forceScanOn = (connectedCount <= 2) || hasRecentTraffic
|
||||
let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn
|
||||
if shouldDuty {
|
||||
if scanDutyTimer == nil {
|
||||
// Start timer to toggle scanning on/off
|
||||
@@ -2138,6 +2412,11 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// If disconnect carried an error (often timeout), apply short backoff to avoid thrash
|
||||
if error != nil {
|
||||
recentConnectTimeouts[peripheralID] = Date()
|
||||
}
|
||||
|
||||
// Clean up references
|
||||
peripherals.removeValue(forKey: peripheralID)
|
||||
|
||||
@@ -2145,9 +2424,12 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
if let peerID = peerID {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
|
||||
// Remove peer completely (they'll be re-added when they reconnect and announce)
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
peers.removeValue(forKey: peerID)
|
||||
// Do not remove peer; mark as not connected but retain for reachability
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if var info = peers[peerID] {
|
||||
info.isConnected = false
|
||||
peers[peerID] = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2162,7 +2444,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
// Attempt to fill freed slot from queue
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
|
||||
// Notify delegate about disconnection on main thread
|
||||
// Notify delegate about disconnection on main thread (direct link dropped)
|
||||
notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -2170,7 +2452,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
if let peerID = peerID {
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
}
|
||||
self.publishFullPeerData()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
@@ -2222,6 +2504,18 @@ extension BLEService {
|
||||
guard candidate.isConnectable else { return }
|
||||
let peripheral = candidate.peripheral
|
||||
let peripheralID = peripheral.identifier.uuidString
|
||||
// Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries
|
||||
if let lastTO = recentConnectTimeouts[peripheralID] {
|
||||
let elapsed = Date().timeIntervalSince(lastTO)
|
||||
if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff {
|
||||
// Requeue the candidate and try again later
|
||||
connectionCandidates.append(candidate)
|
||||
let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed
|
||||
let delay = min(max(2.0, remaining), 15.0)
|
||||
bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() }
|
||||
return
|
||||
}
|
||||
}
|
||||
if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true {
|
||||
// Already in progress; skip
|
||||
bleQueue.async { [weak self] in self?.tryConnectFromQueue() }
|
||||
@@ -2355,6 +2649,10 @@ extension BLEService: CBPeripheralDelegate {
|
||||
// Send announce after subscription is confirmed (force send for new connection)
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Try flushing any spooled directed packets now that we have a link
|
||||
self?.flushDirectedSpool()
|
||||
// Rebroadcast a couple of recent announces to seed the new link
|
||||
self?.rebroadcastRecentAnnounces()
|
||||
}
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning)
|
||||
@@ -2517,6 +2815,10 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
// Send announce to the newly subscribed central after a small delay to avoid overwhelming
|
||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
// Flush any spooled directed packets now that we have a central subscribed
|
||||
self?.flushDirectedSpool()
|
||||
// Rebroadcast a couple of recent announces to seed the new link
|
||||
self?.rebroadcastRecentAnnounces()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2533,9 +2835,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
// Find and disconnect the peer associated with this central
|
||||
let centralUUID = central.identifier.uuidString
|
||||
if let peerID = centralToPeerID[centralUUID] {
|
||||
// Remove peer completely (they'll be re-added when they reconnect)
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
peers.removeValue(forKey: peerID)
|
||||
// Mark peer as not connected; retain for reachability
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
if var info = peers[peerID] {
|
||||
info.isConnected = false
|
||||
peers[peerID] = info
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up mappings
|
||||
@@ -2548,7 +2853,9 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
// Get current peer list (after removal)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) }
|
||||
|
||||
self.delegate?.didDisconnectFromPeer(peerID)
|
||||
self.notifyPeerDisconnectedDebounced(peerID)
|
||||
// Publish snapshots so UnifiedPeerService can refresh icons promptly
|
||||
self.publishFullPeerData()
|
||||
self.delegate?.didUpdatePeerList(currentPeerIDs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
/// Testing-only reset helper
|
||||
func _resetForTesting() {
|
||||
bookmarks.removeAll()
|
||||
membership.removeAll()
|
||||
bookmarkNames.removeAll()
|
||||
persist()
|
||||
persistNames()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Initialize teleported flag from persisted state if a location channel is selected
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -92,23 +99,30 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.requestOneShotLocation()
|
||||
}
|
||||
// Kick off immediately
|
||||
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
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop periodic refreshes when selector UI is dismissed.
|
||||
/// Stop continuous 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) {
|
||||
@@ -122,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on persisted state OR current location vs selected channel
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
let persisted = self.teleportedSet.contains(ch.geohash)
|
||||
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
|
||||
self.teleported = persisted || (currentGH != ch.geohash)
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
let hasMesh = mesh.isPeerConnected(peerID)
|
||||
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
if hasMesh && hasEstablished {
|
||||
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||
if reachableMesh {
|
||||
SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// BLEService will initiate a handshake if needed and queue the message
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
@@ -57,9 +57,9 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
@@ -70,7 +70,9 @@ final class MessageRouter {
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
@@ -101,7 +103,7 @@ final class MessageRouter {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
if mesh.isPeerReachable(peerID) {
|
||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
|
||||
@@ -31,6 +31,7 @@ final class NostrTransport: Transport {
|
||||
func emergencyDisconnectAll() { /* no-op */ }
|
||||
|
||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
||||
func peerNickname(peerID: String) -> String? { nil }
|
||||
func getPeerNicknames() -> [String : String] { [:] }
|
||||
|
||||
|
||||
@@ -90,6 +90,15 @@ 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"
|
||||
|
||||
@@ -15,6 +15,7 @@ struct RelayController {
|
||||
isDirectedEncrypted: Bool,
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
// Suppress obvious non-relays
|
||||
@@ -25,7 +26,8 @@ struct RelayController {
|
||||
// Always relay with no TTL cap for these types
|
||||
let newTTL = (ttl &- 1)
|
||||
// Slight jitter to desynchronize without adding too much latency
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
|
||||
// Tighter for faster multi-hop handshakes and directed DMs
|
||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||
let delayMs = Int.random(in: delayRange)
|
||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||
}
|
||||
@@ -42,15 +44,22 @@ struct RelayController {
|
||||
let prob = baseProb
|
||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
||||
|
||||
// TTL clamping in dense graphs (only for broadcast)
|
||||
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep very low to avoid floods
|
||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
||||
// - Announces in sparse graphs get a bit more headroom
|
||||
let ttlCap: UInt8 = {
|
||||
if degree >= highDegreeThreshold { return 3 }
|
||||
return isAnnounce ? 7 : 6
|
||||
}()
|
||||
let clamped = max(1, min(ttl, ttlCap))
|
||||
let newTTL = clamped &- 1
|
||||
|
||||
// Wider jitter window to allow duplicate suppression to win more often
|
||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||
let delayMs: Int
|
||||
switch degree {
|
||||
case 0...2: delayMs = Int.random(in: 40...100)
|
||||
case 0...2: delayMs = Int.random(in: 10...40)
|
||||
case 3...5: delayMs = Int.random(in: 60...150)
|
||||
case 6...9: delayMs = Int.random(in: 80...180)
|
||||
default: delayMs = Int.random(in: 100...220)
|
||||
|
||||
@@ -29,6 +29,7 @@ protocol Transport: AnyObject {
|
||||
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: String) -> Bool
|
||||
func isPeerReachable(_ peerID: String) -> Bool
|
||||
func peerNickname(peerID: String) -> String?
|
||||
func getPeerNicknames() -> [String: String]
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ enum TransportConfig {
|
||||
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
|
||||
|
||||
// BLE maintenance & thresholds
|
||||
static let bleMaintenanceInterval: TimeInterval = 10.0
|
||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||
static let bleMaintenanceLeewaySeconds: Int = 1
|
||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
||||
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
||||
@@ -71,28 +71,43 @@ enum TransportConfig {
|
||||
static let bleRSSIIsolatedRelaxed: Int = -92
|
||||
static let bleRSSIConnectedThreshold: Int = -85
|
||||
static let bleRSSIHighTimeoutThreshold: Int = -80
|
||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
|
||||
// How long without seeing traffic before we sanity-check the direct link
|
||||
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
||||
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
||||
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
||||
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
||||
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
|
||||
static let bleRecentPacketWindowMaxCount: Int = 100
|
||||
// Keep scanning fully ON when we saw traffic very recently
|
||||
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
|
||||
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
|
||||
static let bleExpectedWritePerFragmentMs: Int = 8
|
||||
static let bleExpectedWriteMaxMs: Int = 2000
|
||||
static let bleFragmentSpacingMs: Int = 6
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
|
||||
// Faster fragment pacing; use slightly tighter spacing for directed trains
|
||||
static let bleFragmentSpacingMs: Int = 5
|
||||
static let bleFragmentSpacingDirectedMs: Int = 4
|
||||
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
|
||||
static let bleDutyOnDurationDense: TimeInterval = 3.0
|
||||
static let bleDutyOffDurationDense: TimeInterval = 15.0
|
||||
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0
|
||||
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
|
||||
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
|
||||
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
|
||||
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0
|
||||
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0
|
||||
static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0
|
||||
static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0
|
||||
|
||||
// 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
|
||||
@@ -126,12 +141,24 @@ enum TransportConfig {
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
|
||||
// BLE operational delays
|
||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0
|
||||
static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6
|
||||
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
|
||||
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
|
||||
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1
|
||||
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05
|
||||
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
|
||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
|
||||
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
|
||||
|
||||
// Store-and-forward for directed packets at relays
|
||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||
|
||||
// Log/UI debounce windows
|
||||
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
||||
static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9
|
||||
static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0
|
||||
|
||||
// Weak-link cooldown after connection timeouts
|
||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||
static let bleWeakLinkRSSICutoff: Int = -90
|
||||
|
||||
// Content hashing / formatting
|
||||
static let contentKeyPrefixLength: Int = 256
|
||||
@@ -148,6 +175,10 @@ 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
|
||||
|
||||
@@ -68,24 +68,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func updatePeers() {
|
||||
let meshPeers = meshService.currentPeerSnapshots()
|
||||
// If we have no direct links at all, peers should not be marked reachable
|
||||
// "Reachable" means mesh-attached via at least one live link.
|
||||
let hasAnyConnected = meshPeers.contains { $0.isConnected }
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Phase 1: Add all connected mesh peers
|
||||
for peerInfo in meshPeers where peerInfo.isConnected {
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.id
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
connected.insert(peerID)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
// Update fingerprint cache
|
||||
@@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
// Phase 3: Sort peers
|
||||
enrichedPeers.sort { lhs, rhs in
|
||||
// Connected first
|
||||
if lhs.isConnected != rhs.isConnected {
|
||||
return lhs.isConnected
|
||||
}
|
||||
// Then favorites
|
||||
if lhs.isFavorite != rhs.isFavorite {
|
||||
return lhs.isFavorite
|
||||
}
|
||||
// Connectivity rank: connected > reachable > others
|
||||
func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
|
||||
let lr = rank(lhs), rr = rank(rhs)
|
||||
if lr != rr { return lr > rr }
|
||||
// Then favorites inside same rank
|
||||
if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
|
||||
// Finally alphabetical
|
||||
return lhs.displayName < rhs.displayName
|
||||
}
|
||||
@@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Update published properties
|
||||
self.peers = enrichedPeers
|
||||
// Phase 5: Filter out offline non-mutual peers and update published properties
|
||||
let filtered = enrichedPeers.filter { p in
|
||||
p.isConnected || p.isReachable || p.isMutualFavorite
|
||||
}
|
||||
self.peers = filtered
|
||||
self.connectedPeerIDs = connected
|
||||
self.favorites = favoritesList
|
||||
self.mutualFavorites = mutualsList
|
||||
@@ -162,14 +167,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
private func buildPeerFromMesh(
|
||||
peerInfo: TransportPeerSnapshot,
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship],
|
||||
meshAttached: Bool
|
||||
) -> BitchatPeer {
|
||||
// Determine reachability based on lastSeen and identity trust
|
||||
let now = Date()
|
||||
let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint()
|
||||
let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false
|
||||
let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false
|
||||
let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||
// A peer is reachable if we recently saw them AND we are attached to the mesh
|
||||
let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention
|
||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||
|
||||
var peer = BitchatPeer(
|
||||
id: peerInfo.id,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
isConnected: true
|
||||
isConnected: peerInfo.isConnected,
|
||||
isReachable: isReachable
|
||||
)
|
||||
|
||||
// Check for favorite status
|
||||
@@ -216,7 +233,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
isConnected: false
|
||||
isConnected: false,
|
||||
isReachable: false
|
||||
)
|
||||
|
||||
peer.favoriteStatus = favorite
|
||||
|
||||
@@ -402,6 +402,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
@Published private(set) var teleportedGeo: Set<String> = [] // lowercased pubkey hex
|
||||
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
|
||||
private var geoSamplingSubs: [String: String] = [:] // subID -> geohash
|
||||
private var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
|
||||
|
||||
// MARK: - Message Delivery Tracking
|
||||
|
||||
@@ -585,6 +586,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if connected {
|
||||
Task { @MainActor in
|
||||
self.resubscribeCurrentGeohash()
|
||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -610,6 +616,70 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
|
||||
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
|
||||
LocationChannelManager.shared.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self = self else { return }
|
||||
let regional = channels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Also observe bookmark changes to update sampling
|
||||
GeohashBookmarksStore.shared.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bookmarks in
|
||||
guard let self = self else { return }
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Kick off initial sampling if we have regional channels or bookmarks
|
||||
do {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if !union.isEmpty {
|
||||
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||
}
|
||||
}
|
||||
// Refresh channels once when authorized to seed sampling
|
||||
LocationChannelManager.shared.$permissionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { state in
|
||||
if state == .authorized { LocationChannelManager.shared.refreshChannels() }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Track teleport flag changes to keep our own teleported marker in sync with regional status
|
||||
LocationChannelManager.shared.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isTeleported in
|
||||
guard let self = self else { return }
|
||||
Task { @MainActor in
|
||||
guard case .location(let ch) = self.activeChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if isTeleported && hasRegional && !inRegional {
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
} else {
|
||||
self.teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Request notification permission
|
||||
NotificationService.shared.requestAuthorization()
|
||||
|
||||
@@ -747,10 +817,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
})
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in self.teleportedGeo = self.teleportedGeo.union([key]) }
|
||||
}
|
||||
}
|
||||
let senderName = self.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
@@ -935,6 +1014,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if unreadPrivateMessages.contains(noiseKeyHex) {
|
||||
return true
|
||||
}
|
||||
// Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey
|
||||
if let nostrHex = peer.nostrPublicKey {
|
||||
let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||
if unreadPrivateMessages.contains(convKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the peer's nickname to check for temporary Nostr peer IDs
|
||||
@@ -1185,7 +1271,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
/// Routes to private chat if one is selected, otherwise broadcasts
|
||||
@MainActor
|
||||
func sendMessage(_ content: String) {
|
||||
guard !content.isEmpty else { return }
|
||||
// Ignore messages that are empty or whitespace-only to prevent blank lines
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
// Check for commands
|
||||
if content.hasPrefix("/") {
|
||||
@@ -1205,7 +1293,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
// Parse mentions from the content
|
||||
// Parse mentions from the content (use original content for user intent)
|
||||
let mentions = parseMentions(from: content)
|
||||
|
||||
// Add message to local display
|
||||
@@ -1220,7 +1308,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
let message = BitchatMessage(
|
||||
sender: displaySender,
|
||||
content: content,
|
||||
content: trimmed,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -1265,7 +1353,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
do {
|
||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
content: trimmed,
|
||||
geohash: ch.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: self.nickname,
|
||||
@@ -1285,7 +1373,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI
|
||||
if LocationChannelManager.shared.teleported {
|
||||
// Only when not in our regional set (and regional list is known)
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
@@ -1322,9 +1413,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
case .location(let ch):
|
||||
// Sanitize existing timeline (filter any prior empty-content entries)
|
||||
var arr = geoTimelines[ch.geohash] ?? []
|
||||
let before = arr.count
|
||||
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
if arr.count != before { geoTimelines[ch.geohash] = arr }
|
||||
// Ensure chronological order when returning to a geohash
|
||||
if arr.count > 1 {
|
||||
arr.sort { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
// Persist the cleaned/sorted timeline for this geohash
|
||||
geoTimelines[ch.geohash] = arr
|
||||
messages = arr
|
||||
}
|
||||
// Unsubscribe previous
|
||||
@@ -1343,14 +1438,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard case .location(let ch) = channel else { return }
|
||||
currentGeohash = ch.geohash
|
||||
// Ensure self appears immediately in the people list; mark teleported state if applicable
|
||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
||||
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
self.recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||
if LocationChannelManager.shared.teleported {
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
let key = id.publicKeyHex.lowercased()
|
||||
if LocationChannelManager.shared.teleported && hasRegional && !inRegional {
|
||||
teleportedGeo = teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
} else {
|
||||
teleportedGeo.remove(key)
|
||||
}
|
||||
}
|
||||
let subID = "geo-\(ch.geohash)"
|
||||
@@ -1379,10 +1478,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
})
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor in
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
// Avoid marking our own key from historical events; rely on manager.teleported for self
|
||||
let isSelf: Bool = {
|
||||
if let gh = self.currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor in
|
||||
self.teleportedGeo = self.teleportedGeo.union([key])
|
||||
SecureLogger.log("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(self.teleportedGeo.count)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
@@ -1734,8 +1842,75 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
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 }
|
||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||
let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
let existingCount: Int = {
|
||||
let map = self.geoParticipants[gh] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}()
|
||||
// Update participants for this specific geohash
|
||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !content.isEmpty else { return }
|
||||
// Respect geohash blocks
|
||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
// Skip self identity for this geohash
|
||||
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
||||
// Only trigger when there were zero participants in this geohash recently
|
||||
guard existingCount == 0 else { return }
|
||||
// Avoid notifications for old sampled events when launching or (re)subscribing
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||
// Foreground policy: allow if it's a different geohash than the one currently open
|
||||
#if os(iOS)
|
||||
if UIApplication.shared.applicationState == .active {
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if NSApplication.shared.isActive {
|
||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
||||
}
|
||||
#endif
|
||||
// Cooldown per geohash
|
||||
let now = Date()
|
||||
let last = self.lastGeoNotificationAt[gh] ?? .distantPast
|
||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||
// Compose a short preview
|
||||
let preview: String = {
|
||||
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
||||
if content.count <= maxLen { return content }
|
||||
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
||||
return String(content[..<idx]) + "…"
|
||||
}()
|
||||
Task { @MainActor in
|
||||
self.lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
var arr = self.geoTimelines[gh] ?? []
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = self.geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if !arr.contains(where: { $0.id == msg.id }) {
|
||||
arr.append(msg)
|
||||
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
|
||||
self.geoTimelines[gh] = arr
|
||||
}
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1874,6 +2049,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Determine routing method and recipient nickname
|
||||
guard let noiseKey = Data(hexString: peerID) else { return }
|
||||
let isConnected = meshService.isPeerConnected(peerID)
|
||||
let isReachable = meshService.isPeerReachable(peerID)
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
@@ -1913,8 +2089,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Trigger UI update for sent message
|
||||
objectWillChange.send()
|
||||
|
||||
// Send via appropriate transport (BLE if connected, else Nostr when possible)
|
||||
if isConnected || (isMutualFavorite && hasNostrKey) {
|
||||
// Send via appropriate transport (BLE if connected/reachable, else Nostr when possible)
|
||||
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
||||
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
||||
// Optimistically mark as sent for both transports; delivery/read will update subsequently
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
@@ -2542,16 +2718,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// If we know the original transport, use it for the read receipt
|
||||
// If this originated over Nostr, skip (handled by Nostr code paths)
|
||||
if originalTransport == "nostr" {
|
||||
// Skip read receipts for Nostr messages - unnecessary complexity
|
||||
// The radical simplification plan says to accept occasional loss
|
||||
} else if meshService.peerNickname(peerID: actualPeerID) != nil {
|
||||
// Use mesh for connected peers (default behavior)
|
||||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||||
} else {
|
||||
// Skip read receipts for offline peers - fire and forget principle
|
||||
return
|
||||
}
|
||||
// Use router to decide (mesh if reachable, else Nostr if available)
|
||||
messageRouter.sendReadReceipt(receipt, to: actualPeerID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -3031,8 +3203,29 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } }
|
||||
return false
|
||||
}
|
||||
// Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd)
|
||||
func attachedToMention(_ r: NSRange) -> Bool {
|
||||
if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex {
|
||||
var i = content.index(before: nsRange.lowerBound)
|
||||
while true {
|
||||
let ch = content[i]
|
||||
if ch.isWhitespace || ch.isNewline { break }
|
||||
if ch == "@" { return true }
|
||||
if i == content.startIndex { break }
|
||||
i = content.index(before: i)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Helper: ensure '#' starts a new token (start-of-line or whitespace before '#')
|
||||
func isStandaloneHashtag(_ r: NSRange) -> Bool {
|
||||
guard let nsRange = Range(r, in: content) else { return false }
|
||||
if nsRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: nsRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) {
|
||||
for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) {
|
||||
allMatches.append((match.range(at: 0), "hashtag"))
|
||||
}
|
||||
for match in mentionMatches {
|
||||
@@ -3140,12 +3333,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return false
|
||||
}()
|
||||
// Also require the '#' to start a new token (whitespace or start-of-line before '#')
|
||||
let standalone: Bool = {
|
||||
if nsRange.lowerBound == content.startIndex { return true }
|
||||
let prev = content.index(before: nsRange.lowerBound)
|
||||
return content[prev].isWhitespace || content[prev].isNewline
|
||||
}()
|
||||
var tagStyle = AttributeContainer()
|
||||
tagStyle.font = isSelf
|
||||
? .system(size: 14, weight: .bold, design: .monospaced)
|
||||
: .system(size: 14, design: .monospaced)
|
||||
tagStyle.foregroundColor = baseColor
|
||||
if isGeohash && !attachedToMention, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||
if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") {
|
||||
tagStyle.link = url
|
||||
tagStyle.underlineStyle = .single
|
||||
}
|
||||
@@ -3484,14 +3683,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
func colorForPeerSeed(_ seed: String, isDark: Bool) -> Color {
|
||||
let cacheKey = seed + (isDark ? "|dark" : "|light")
|
||||
if let cached = peerColorCache[cacheKey] { return cached }
|
||||
var hue = Double(djb2(seed) % 360) / 360.0
|
||||
// Avoid orange (~30°) reserved for self
|
||||
let h = djb2(seed)
|
||||
var hue = Double(h % 1000) / 1000.0
|
||||
let orange = 30.0 / 360.0
|
||||
if abs(hue - orange) < TransportConfig.uiColorHueAvoidanceDelta {
|
||||
hue = fmod(hue + TransportConfig.uiColorHueOffset, 1.0)
|
||||
}
|
||||
let saturation: Double = isDark ? 0.80 : 0.70
|
||||
let brightness: Double = isDark ? 0.75 : 0.45
|
||||
let sRand = Double((h >> 17) & 0x3FF) / 1023.0
|
||||
let bRand = Double((h >> 27) & 0x3FF) / 1023.0
|
||||
let sBase: Double = isDark ? 0.80 : 0.70
|
||||
let sRange: Double = 0.20
|
||||
let bBase: Double = isDark ? 0.75 : 0.45
|
||||
let bRange: Double = isDark ? 0.16 : 0.14
|
||||
let saturation = min(1.0, max(0.50, sBase + (sRand - 0.5) * sRange))
|
||||
let brightness = min(1.0, max(0.35, bBase + (bRand - 0.5) * bRange))
|
||||
let c = Color(hue: hue, saturation: saturation, brightness: brightness)
|
||||
peerColorCache[cacheKey] = c
|
||||
return c
|
||||
@@ -3499,41 +3704,35 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||
var seed: String
|
||||
if let spid = message.senderPeerID {
|
||||
if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") {
|
||||
// Normalize to the bare short id, then prefer full mapping when available
|
||||
let bare: String = {
|
||||
if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) }
|
||||
if spid.hasPrefix("nostr_") { return String(spid.dropFirst(6)) }
|
||||
return spid
|
||||
}()
|
||||
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
|
||||
seed = "nostr:" + full
|
||||
} else if spid.count == 16, let full = getNoiseKeyForShortID(spid)?.lowercased() {
|
||||
seed = "noise:" + full
|
||||
return getNostrPaletteColor(for: full, isDark: isDark)
|
||||
} else if spid.count == 16 {
|
||||
// Mesh short ID
|
||||
return getPeerPaletteColor(for: spid, isDark: isDark)
|
||||
} else {
|
||||
seed = spid.lowercased()
|
||||
return getPeerPaletteColor(for: spid.lowercased(), isDark: isDark)
|
||||
}
|
||||
} else {
|
||||
seed = message.sender.lowercased()
|
||||
}
|
||||
return colorForPeerSeed(seed, isDark: isDark)
|
||||
// Fallback when we only have a display name
|
||||
return colorForPeerSeed(message.sender.lowercased(), isDark: isDark)
|
||||
}
|
||||
|
||||
// Public helpers for views to color peers consistently in lists
|
||||
@MainActor
|
||||
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||
return colorForPeerSeed("nostr:" + pubkeyHexLowercased.lowercased(), isDark: isDark)
|
||||
return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func colorForMeshPeer(id peerID: String, isDark: Bool) -> Color {
|
||||
// Mirror message coloring: prefer stable full noise key mapping when available, else short ID
|
||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||
return colorForPeerSeed("noise:" + full, isDark: isDark)
|
||||
}
|
||||
return colorForPeerSeed(peerID.lowercased(), isDark: isDark)
|
||||
return getPeerPaletteColor(for: peerID, isDark: isDark)
|
||||
}
|
||||
|
||||
private func trimMeshTimelineIfNeeded() {
|
||||
@@ -3542,6 +3741,276 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Peer List Minimal-Distance Palette
|
||||
private var peerPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var peerPaletteSeeds: [String: String] = [:] // peerID -> seed used
|
||||
|
||||
@MainActor
|
||||
private func meshSeed(for peerID: String) -> String {
|
||||
if let full = getNoiseKeyForShortID(peerID)?.lowercased() {
|
||||
return "noise:" + full
|
||||
}
|
||||
return peerID.lowercased()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func getPeerPaletteColor(for peerID: String, isDark: Bool) -> Color {
|
||||
// Ensure palette up to date for current peer set and seeds
|
||||
rebuildPeerPaletteIfNeeded()
|
||||
|
||||
let entry = (isDark ? peerPaletteDark[peerID] : peerPaletteLight[peerID])
|
||||
let orange = Color.orange
|
||||
if peerID == meshService.myPeerID { return orange }
|
||||
let saturation: Double = isDark ? 0.80 : 0.70
|
||||
let baseBrightness: Double = isDark ? 0.75 : 0.45
|
||||
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
// Fallback to seed color if not in palette (e.g., transient)
|
||||
let seed = meshSeed(for: peerID)
|
||||
return colorForPeerSeed(seed, isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildPeerPaletteIfNeeded() {
|
||||
// Build current peer->seed map (excluding self)
|
||||
let myID = meshService.myPeerID
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in allPeers where p.id != myID {
|
||||
currentSeeds[p.id] = meshSeed(for: p.id)
|
||||
}
|
||||
// If seeds unchanged and palette exists for both themes, skip
|
||||
if currentSeeds == peerPaletteSeeds,
|
||||
peerPaletteLight.keys.count == currentSeeds.count,
|
||||
peerPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
peerPaletteSeeds = currentSeeds
|
||||
|
||||
// Generate evenly spaced hue slots avoiding self-orange range
|
||||
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
|
||||
let avoidCenter = 30.0 / 360.0
|
||||
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
// Safety: if avoidance consumed all (shouldn't happen), fall back to full slots
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
// Helper to compute circular distance
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
// Assign slots to peers to maximize minimal distance, deterministically
|
||||
let peers = currentSeeds.keys.sorted() // stable order
|
||||
// Preferred slot index by seed (wrapping to available slots)
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = djb2(currentSeeds[id] ?? id)
|
||||
// Map to available slot range deterministically
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
func assign(for seeds: [String: String]) -> [String: (slot: Int, ring: Int, hue: Double)] {
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
// Keep previous assignments if still valid to minimize churn
|
||||
let prev = peerPaletteLight.isEmpty ? peerPaletteDark : peerPaletteLight
|
||||
for (id, entry) in prev {
|
||||
if seeds.keys.contains(id), entry.slot < slots.count { // slot index still valid
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
// First ring assignment using free slots
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
// If a preferred slot free, take it
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
// Choose free slot maximizing minimal distance to used hues
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
// Bias toward preferred index for stability
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow peers: assign additional rings by reusing slots with stable preference
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
// Spread over slots by rotating from preferred with a golden-step
|
||||
let goldenStep = 7 // small prime step for dispersion
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
let mapping = assign(for: currentSeeds)
|
||||
peerPaletteLight = mapping
|
||||
peerPaletteDark = mapping
|
||||
}
|
||||
|
||||
// MARK: - Nostr People Minimal-Distance Palette (same algo)
|
||||
private var nostrPaletteLight: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteDark: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
private var nostrPaletteSeeds: [String: String] = [:] // pubkey -> seed used
|
||||
|
||||
@MainActor
|
||||
private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||
rebuildNostrPaletteIfNeeded()
|
||||
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if let me = myHex, pubkeyHexLowercased == me { return .orange }
|
||||
let saturation: Double = isDark ? 0.80 : 0.70
|
||||
let baseBrightness: Double = isDark ? 0.75 : 0.45
|
||||
let ringDelta = isDark ? TransportConfig.uiPeerPaletteRingBrightnessDeltaDark : TransportConfig.uiPeerPaletteRingBrightnessDeltaLight
|
||||
if let e = entry {
|
||||
let brightness = min(1.0, max(0.0, baseBrightness + ringDelta * Double(e.ring)))
|
||||
return Color(hue: e.hue, saturation: saturation, brightness: brightness)
|
||||
}
|
||||
// Fallback to seed color if not in palette (e.g., transient)
|
||||
return colorForPeerSeed("nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func rebuildNostrPaletteIfNeeded() {
|
||||
// Build seeds map from currently visible geohash people (excluding self)
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
|
||||
return id.publicKeyHex.lowercased()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
let people = visibleGeohashPeople()
|
||||
var currentSeeds: [String: String] = [:]
|
||||
for p in people where p.id != myHex { currentSeeds[p.id] = "nostr:" + p.id }
|
||||
|
||||
if currentSeeds == nostrPaletteSeeds,
|
||||
nostrPaletteLight.keys.count == currentSeeds.count,
|
||||
nostrPaletteDark.keys.count == currentSeeds.count {
|
||||
return
|
||||
}
|
||||
nostrPaletteSeeds = currentSeeds
|
||||
|
||||
let slotCount = max(8, TransportConfig.uiPeerPaletteSlots)
|
||||
let avoidCenter = 30.0 / 360.0
|
||||
let avoidDelta = TransportConfig.uiColorHueAvoidanceDelta
|
||||
var slots: [Double] = []
|
||||
for i in 0..<slotCount {
|
||||
let hue = Double(i) / Double(slotCount)
|
||||
if abs(hue - avoidCenter) < avoidDelta { continue }
|
||||
slots.append(hue)
|
||||
}
|
||||
if slots.isEmpty {
|
||||
for i in 0..<slotCount { slots.append(Double(i) / Double(slotCount)) }
|
||||
}
|
||||
|
||||
func circDist(_ a: Double, _ b: Double) -> Double {
|
||||
let d = abs(a - b)
|
||||
return d > 0.5 ? 1.0 - d : d
|
||||
}
|
||||
|
||||
let peers = currentSeeds.keys.sorted()
|
||||
let prefIndex: [String: Int] = Dictionary(uniqueKeysWithValues: peers.map { id in
|
||||
let h = djb2(currentSeeds[id] ?? id)
|
||||
let idx = Int(h % UInt64(slots.count))
|
||||
return (id, idx)
|
||||
})
|
||||
|
||||
var mapping: [String: (slot: Int, ring: Int, hue: Double)] = [:]
|
||||
var usedSlots = Set<Int>()
|
||||
var usedHues: [Double] = []
|
||||
|
||||
let prev = nostrPaletteLight.isEmpty ? nostrPaletteDark : nostrPaletteLight
|
||||
for (id, entry) in prev {
|
||||
if peers.contains(id), entry.slot < slots.count {
|
||||
mapping[id] = (entry.slot, entry.ring, slots[entry.slot])
|
||||
usedSlots.insert(entry.slot)
|
||||
usedHues.append(slots[entry.slot])
|
||||
}
|
||||
}
|
||||
|
||||
let unassigned = peers.filter { mapping[$0] == nil }
|
||||
for id in unassigned {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
if !usedSlots.contains(preferred) && preferred < slots.count {
|
||||
mapping[id] = (preferred, 0, slots[preferred])
|
||||
usedSlots.insert(preferred)
|
||||
usedHues.append(slots[preferred])
|
||||
continue
|
||||
}
|
||||
var bestSlot: Int? = nil
|
||||
var bestScore: Double = -1
|
||||
for sIdx in 0..<slots.count where !usedSlots.contains(sIdx) {
|
||||
let hue = slots[sIdx]
|
||||
let minDist = usedHues.isEmpty ? 1.0 : usedHues.map { circDist(hue, $0) }.min() ?? 1.0
|
||||
let bias = 1.0 - (Double((abs(sIdx - (prefIndex[id] ?? 0)) % slots.count)) / Double(slots.count))
|
||||
let score = minDist + 0.05 * bias
|
||||
if score > bestScore { bestScore = score; bestSlot = sIdx }
|
||||
}
|
||||
if let s = bestSlot {
|
||||
mapping[id] = (s, 0, slots[s])
|
||||
usedSlots.insert(s)
|
||||
usedHues.append(slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
let stillUnassigned = peers.filter { mapping[$0] == nil }
|
||||
if !stillUnassigned.isEmpty {
|
||||
for (idx, id) in stillUnassigned.enumerated() {
|
||||
let preferred = prefIndex[id] ?? 0
|
||||
let goldenStep = 7
|
||||
let s = (preferred + idx * goldenStep) % slots.count
|
||||
mapping[id] = (s, 1, slots[s])
|
||||
}
|
||||
}
|
||||
|
||||
nostrPaletteLight = mapping
|
||||
nostrPaletteDark = mapping
|
||||
}
|
||||
|
||||
// Clear the current public channel's timeline (visible + persistent buffer)
|
||||
@MainActor
|
||||
func clearCurrentPublicTimeline() {
|
||||
@@ -3929,11 +4398,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
||||
Task { @MainActor in
|
||||
let publicMentions = parseMentions(from: content)
|
||||
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicMentions = parseMentions(from: normalized)
|
||||
let msg = BitchatMessage(
|
||||
id: UUID().uuidString,
|
||||
sender: nickname,
|
||||
content: content,
|
||||
content: normalized,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -4102,19 +4572,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Cancel any pending reset if peers are back
|
||||
self.networkResetTimer?.invalidate()
|
||||
self.networkResetTimer = nil
|
||||
// Only count mesh peers (actually connected via Bluetooth)
|
||||
// Count mesh peers that are connected OR recently reachable via mesh relays
|
||||
let meshPeers = peers.filter { peerID in
|
||||
self.meshService.isPeerConnected(peerID)
|
||||
self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID)
|
||||
}
|
||||
|
||||
// Check if we have new mesh peers we haven't seen recently
|
||||
// Rising-edge only: previously zero peers, now > 0 peers
|
||||
let currentPeerSet = Set(meshPeers)
|
||||
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
||||
// Send notification if:
|
||||
// 1. We have mesh peers (not just Nostr-only)
|
||||
// 2. There are new peers we haven't seen (rising-edge)
|
||||
// 3. We haven't already notified since the last sustained-empty period
|
||||
if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable {
|
||||
let hadNone = self.recentlySeenPeers.isEmpty
|
||||
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
||||
self.hasNotifiedNetworkAvailable = true
|
||||
self.lastNetworkNotificationTime = Date()
|
||||
self.recentlySeenPeers = currentPeerSet
|
||||
@@ -4123,16 +4589,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .info)
|
||||
}
|
||||
} else {
|
||||
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
||||
if self.networkResetTimer == nil {
|
||||
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.hasNotifiedNetworkAvailable = false
|
||||
self.recentlySeenPeers.removeAll()
|
||||
self.networkResetTimer = nil
|
||||
SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
// No peers — immediately reset to allow next rising-edge to notify
|
||||
self.hasNotifiedNetworkAvailable = false
|
||||
self.recentlySeenPeers.removeAll()
|
||||
if self.networkResetTimer != nil {
|
||||
self.networkResetTimer?.invalidate()
|
||||
self.networkResetTimer = nil
|
||||
}
|
||||
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
|
||||
// Register ephemeral sessions for all connected peers
|
||||
@@ -5318,33 +5782,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard channelMatches else { return }
|
||||
|
||||
// Background nudge: notify on new activity after inactivity threshold in current channel
|
||||
#if os(iOS)
|
||||
if UIApplication.shared.applicationState != .active {
|
||||
let channelKey: String = {
|
||||
switch activeChannel {
|
||||
case .mesh: return "mesh"
|
||||
case .location(let ch): return "geo:\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let now = Date()
|
||||
if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold {
|
||||
// Optional: simple cooldown to avoid duplicate bursts
|
||||
let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast
|
||||
if now.timeIntervalSince(lastNotified) >= 60 {
|
||||
let title = activeChannelDisplayName()
|
||||
let body = "new chats!"
|
||||
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)")
|
||||
lastPublicActivityNotifyAt[channelKey] = now
|
||||
}
|
||||
}
|
||||
lastPublicActivityAt[channelKey] = now
|
||||
}
|
||||
#endif
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content)
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
enqueuePublic(finalMessage)
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
enqueuePublic(finalMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -459,7 +460,13 @@ struct ContentView: View {
|
||||
}
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
// Do not mark teleported when opening a geohash that is in our regional set.
|
||||
// If availableChannels is empty (e.g., cold start), defer marking and let
|
||||
// LocationChannelManager compute teleported based on actual location.
|
||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
LocationChannelManager.shared.markTeleported(for: gh, true)
|
||||
}
|
||||
LocationChannelManager.shared.select(ChannelID.location(ch))
|
||||
}
|
||||
.onTapGesture(count: 3) {
|
||||
@@ -1036,9 +1043,8 @@ struct ContentView: View {
|
||||
case .mesh:
|
||||
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
|
||||
guard peer.id != viewModel.meshService.myPeerID else { return }
|
||||
let isMeshConnected = peer.isConnected
|
||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isMutualFavorite { counts.others += 1 }
|
||||
if peer.isConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isReachable { counts.others += 1 }
|
||||
}
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
|
||||
@@ -1113,6 +1119,15 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open unread private chat")
|
||||
}
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
}
|
||||
// Location channels button '#'
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
let badgeText: String = {
|
||||
@@ -1181,20 +1196,11 @@ struct ContentView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func privateHeaderContent(for privatePeerID: String) -> some View {
|
||||
// Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe).
|
||||
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
|
||||
let headerPeerID: String = {
|
||||
if privatePeerID.count == 16 {
|
||||
let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID)
|
||||
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
||||
return stable
|
||||
}
|
||||
} else if privatePeerID.count == 64 {
|
||||
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
|
||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
|
||||
return short
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
return privatePeerID
|
||||
}()
|
||||
@@ -1209,10 +1215,29 @@ struct ContentView: View {
|
||||
return "#\(ch.geohash)/@\(disp)"
|
||||
}
|
||||
}
|
||||
return peer?.displayName ??
|
||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||
"Unknown"
|
||||
// Try mesh/unified peer display
|
||||
if let name = peer?.displayName { return name }
|
||||
// Try direct mesh nickname (connected-only)
|
||||
if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name }
|
||||
// Try favorite nickname by stable Noise key
|
||||
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 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 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 }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}()
|
||||
let isNostrAvailable: Bool = {
|
||||
guard let connectionState = peer?.connectionState else {
|
||||
@@ -1247,6 +1272,12 @@ struct ContentView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Connected via mesh")
|
||||
case .meshReachable:
|
||||
// point.3 filled icon for reachable via mesh (not directly connected)
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
case .nostrAvailable:
|
||||
// Purple globe for Nostr
|
||||
Image(systemName: "globe")
|
||||
@@ -1257,6 +1288,12 @@ struct ContentView: View {
|
||||
// Should not happen for PM header, but handle gracefully
|
||||
EmptyView()
|
||||
}
|
||||
} else if viewModel.meshService.isPeerReachable(headerPeerID) {
|
||||
// Fallback: reachable via mesh but not in current peer list
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.accessibilityLabel("Reachable via mesh")
|
||||
} else if isNostrAvailable {
|
||||
// Fallback to Nostr if peer not in list but is mutual favorite
|
||||
Image(systemName: "globe")
|
||||
@@ -1275,7 +1312,14 @@ struct ContentView: View {
|
||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||
// Use short peer ID if available for encryption status (sessions keyed by short ID)
|
||||
let statusPeerID: String = {
|
||||
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
return short
|
||||
}
|
||||
return headerPeerID
|
||||
}()
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
|
||||
@@ -41,9 +41,27 @@ struct FingerprintView: View {
|
||||
.padding()
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Peer info
|
||||
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown"
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: peerID)
|
||||
// Prefer short mesh ID for session/encryption status
|
||||
let statusPeerID: String = {
|
||||
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 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) {
|
||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}()
|
||||
// Accurate encryption state based on short ID session
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||
|
||||
HStack {
|
||||
if let icon = encryptionStatus.icon {
|
||||
@@ -74,7 +92,7 @@ struct FingerprintView: View {
|
||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
if let fingerprint = viewModel.getFingerprint(for: peerID) {
|
||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||
Text(formatFingerprint(fingerprint))
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
@@ -8,6 +8,7 @@ import AppKit
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -83,29 +84,22 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
manager.beginLiveRefresh()
|
||||
// Begin multi-channel sampling for counts
|
||||
let ghs = manager.availableChannels.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
// Geohash sampling is now managed by ChatViewModel globally
|
||||
}
|
||||
.onDisappear {
|
||||
manager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
.onChange(of: manager.permissionState) { newValue in
|
||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { _ in }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
@@ -119,7 +113,21 @@ struct LocationChannelsSheet: View {
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||
channelRow(
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
@@ -195,6 +203,48 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("bookmarked")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
@@ -227,14 +277,24 @@ struct LocationChannelsSheet: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
@ViewBuilder
|
||||
private func channelRow(
|
||||
title: String,
|
||||
subtitlePrefix: String,
|
||||
subtitleName: String? = nil,
|
||||
subtitleNameBold: Bool = false,
|
||||
isSelected: Bool,
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
@@ -256,6 +316,8 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,11 +327,11 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -289,20 +351,28 @@ 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 { return acc + 1 }
|
||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
||||
// Bookmarked list: show the #geohash as the main label
|
||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "#\(geohash) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
|
||||
@@ -52,6 +52,11 @@ struct MeshPeerList: View {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
@@ -110,6 +115,14 @@ struct MeshPeerList: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Unread message indicator for this peer
|
||||
if !isMe, item.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help("New messages")
|
||||
}
|
||||
|
||||
if !isMe {
|
||||
Button(action: { onToggleFavorite(peer.id) }) {
|
||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear persisted state before each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean after each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
// Start clean
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
// Add with mixed case and hash prefix
|
||||
store.toggle("#U4PRUY")
|
||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||
|
||||
// Toggling again removes
|
||||
store.toggle("u4pruy")
|
||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
}
|
||||
|
||||
func testPersistenceWritten() throws {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||
XCTAssertTrue(arr.contains("ezs42"))
|
||||
XCTAssertTrue(arr.contains("u4pruy"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user