Compare commits

..
Author SHA1 Message Date
jack 4d4c596a17 UI: diversify peer colors; smarter geo notifications; 21m live location
- Use minimal-distance hue palette for mesh and geohash lists; align chat sender colors with list palette.
- Add foreground geo notifications for different channels; deep-link to bitchat://geohash/<gh>; per-geohash 60s cooldown; respect self/blocks.
- Global geohash sampling runs outside the sheet; delegate handles deeplinks and suppresses when already in-channel.
- Switch location channel sheet to continuous CoreLocation with 21m distance filter; add config knob.
- Only link geohash hashtags when standalone (no @name#abcd or word#abcd).
- Include mesh-reachable peers in mesh counts and in "bitchatters nearby" notification.
- Add TransportConfig knobs for palette and geo notifications.
2025-08-26 18:39:48 +02:00
96c6fc0c0d Improve mesh relaying, presence, and DM robustness; signed public msgs; reachability UI; quicker announces; store-and-forward (#527)
* Sign public broadcasts; verify relayed messages via persisted signing keys; keep scheduled relays in sparse graphs and speed their jitter; persist announce signing key for offline auth; add short backoff after disconnect errors to reduce reconnect thrash

* Add connected vs reachable model: retain peers after link drop, expire after reachability window; expose all peers in snapshots; compute isReachable in UI; add meshReachable state and sorting; avoid removing peers on link events; notify UI on stale removals

* ContentView: handle new .meshReachable connection state in header icon switch (exhaustive switch fix)

* Logs: tag relayed announces as 'Reachable via mesh' and annotate public message logs with (direct|mesh) path for easier field analysis

* Fix syntax error: remove stray else/log inserted into writeOrEnqueue; keep logs clean

* UI: use 'point.3.connected.trianglepath.dotted' for mesh-reachable; change people count to include connected+reachable (exclude Nostr-only)

* UI: switch to 'point.3.filled.connected.trianglepath.dotted' for mesh-reachable icons in list and header

* Reachability: reduce retention to 21s for all peers (verified and unverified) to minimize stale presence

* mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance

* Verification sheet: compute encryption status and fingerprint using short mesh ID mapping (fix 'not encrypted/handshake' for DMs with stable key)

* Announce cadence: faster discovery (4s), sparse 15±4s, dense 30±8s; initial 0.6s; post-subscribe 50ms; min-force 150ms; maintenance 5s; proactive announces on handshake + recent-traffic nudge

* Relay: increase broadcast TTL cap in sparse graphs to 6; tighten jitter for handshake (10–35ms) and directed (20–60ms) relays

* Range/robustness: store-and-forward for directed packets (15s) with flush on new links + periodic; announces: no subset + afterglow re-announce on first-seen; adaptive scanning: force ON when <=2 neighbors or recent traffic

* Fix warnings: remove unused msgID and unused mutable var in directed spool flush

* Announces: TTL 7 (sparse only) via RelayController; no fanout subset for announces; neighbor-change rebroadcast of last 2–3 announces. Fragments: faster pacing (5ms global, 4ms directed).

* Peer list: real-time icon updates by publishing snapshots on connectivity checks; add unread message indicator (envelope) next to peers with unread DMs

* UI: unread envelope uses orange; hasUnreadMessages checks Nostr conv key for peers with known Nostr pubkeys (geohash DM consistency)

* Logs/robustness: debounce disconnect notifications (1.5s), debounce 'reconnected' logs (2s), add weak-link cooldown after timeouts on very weak RSSI (<= -90)

* Peer icons: faster, accurate reachability\n\n- Run connectivity checks every maintenance tick (5s)\n- Publish peer snapshots on central unsubscribe for instant UI refresh\n- Lower inactivity timeout to 8s and disconnect debounce to 0.9s\n- Gate reachability on mesh-attached (>=1 direct link); no links => no reachable peers\n- Keep 21s retention for verified/unverified, but only when attached to mesh\n\nImproves list responsiveness when walking out of range and prevents stale 'reachable' states when isolated.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-26 17:28:08 +02:00
a7d5b2d7d9 Centralize remaining magic numbers; finalize logging hygiene across UI/BLE/Nostr (#522)
* docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize)

* project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen)

* chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM)

* project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources

* chore(BLE): move connect/duty/announce constants to TransportConfig and reference them

* chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication

* chore(BLE): trim raw hex dump on central decode failure to length+prefix

* project: remove duplicate TransportConfig.swift entries from Sources build phases

* chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport

* chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel

* chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules

* chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays

* refactor(config): centralize remaining magic numbers and finalize log hygiene

Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.

* project: add TransportConfig.swift to Share Extension target to fix build

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 21:18:51 +02:00
19 changed files with 1163 additions and 252 deletions
+10 -13
View File
@@ -328,7 +328,6 @@
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -743,14 +742,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 */,
@@ -803,14 +801,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 */,
@@ -1007,7 +1004,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 +1035,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 +1090,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 +1122,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 +1211,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 +1304,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";
+17
View File
@@ -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])
+2
View File
@@ -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?
}
@@ -229,6 +229,91 @@ class SecureIdentityStateManager {
return cache.socialIdentities[fingerprint]
}
}
// 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 {
+9 -1
View File
@@ -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
+428 -121
View File
@@ -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
}
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)
// Create typed payload: [type byte] + [message ID]
var payload = Data([NoisePayloadType.readReceipt.rawValue])
payload.append(contentsOf: receipt.originalMessageID.utf8)
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
@@ -1062,6 +1114,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
@@ -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,16 +1507,52 @@ 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)
}
}
}
// Notify UI on main thread
notifyUI { [weak self] in
guard let self = self else { return }
@@ -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
@@ -1440,9 +1596,41 @@ final class BLEService: NSObject {
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
// 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,17 +1639,17 @@ 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
self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts)
@@ -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)
}
}
}
@@ -1696,6 +1917,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() {
@@ -1748,6 +1981,16 @@ final class BLEService: NSObject {
let target = base + Double.random(in: -jitter...jitter)
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 {
@@ -1761,15 +2004,18 @@ 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
@@ -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
@@ -2137,6 +2411,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)
}
}
+14 -7
View File
@@ -92,23 +92,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) {
+11 -9
View File
@@ -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)
+1
View File
@@ -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"
+13 -4
View File
@@ -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)
+1
View File
@@ -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]
+42 -11
View File
@@ -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 connectedreachable 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
+35 -17
View File
@@ -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
+398 -34
View File
@@ -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
@@ -609,6 +610,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Background: keep sampling nearby geohashes 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 ghs = channels.map { $0.geohash }
Task { @MainActor in
self.beginGeohashSampling(for: ghs)
}
}
.store(in: &cancellables)
// Kick off initial sampling if we already have channels
if !LocationChannelManager.shared.availableChannels.isEmpty {
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
}
// 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)
// Request notification permission
NotificationService.shared.requestAuthorization()
@@ -935,6 +960,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
@@ -1736,6 +1768,39 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
// Update participants for this specific geohash
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify on new message activity in this geohash (sampling across channels)
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 }
// Foreground policy: allow if it's a different geohash than the one currently open
// Suppress only when app is active AND we're already in this same geohash channel
#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
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
}
}
@@ -1874,6 +1939,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 +1979,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 +2608,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 +3093,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 +3223,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 +3573,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 +3594,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 +3631,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() {
@@ -4102,9 +4461,9 @@ 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
@@ -5334,7 +5693,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
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)")
if case .location(let ch) = activeChannel {
// Attach deep link to open this geohash directly
NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body)
} else {
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)")
}
lastPublicActivityNotifyAt[channelKey] = now
}
}
+49 -21
View File
@@ -1036,9 +1036,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
@@ -1181,20 +1180,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 +1199,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 +1256,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 +1272,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 +1296,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))
+22 -4
View File
@@ -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)
+4 -10
View File
@@ -83,24 +83,17 @@ 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 {
@@ -289,9 +282,10 @@ 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
}
}
+13
View File
@@ -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")