diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 46a1412e..e16cc698 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -528,8 +528,17 @@ final class BLEService: NSObject { } return peerID }() - // A peer is reachable if we have a recent entry for it (connected or recently seen via mesh) - return collectionsQueue.sync { peers[shortID] != nil } + 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? { @@ -1995,10 +2004,8 @@ 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 { @@ -2847,6 +2854,8 @@ extension BLEService: CBPeripheralManagerDelegate { let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } self.notifyPeerDisconnectedDebounced(peerID) + // Publish snapshots so UnifiedPeerService can refresh icons promptly + self.publishFullPeerData() self.delegate?.didUpdatePeerList(currentPeerIDs) } } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 4b34bb7a..3614a7c9 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -71,7 +71,9 @@ enum TransportConfig { static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIHighTimeoutThreshold: Int = -80 - static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 + // How long without seeing traffic before we sanity-check the direct link + // Lowered to make connected→reachable icon changes react faster when walking out of range + static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 // How long to retain a peer as "reachable" (not directly connected) since lastSeen static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified @@ -144,7 +146,8 @@ enum TransportConfig { static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 // Log/UI debounce windows - static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 1.5 + // 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 diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index 53dd3f59..9675a4aa 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -68,6 +68,9 @@ 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] = [] @@ -81,7 +84,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { let peer = buildPeerFromMesh( peerInfo: peerInfo, - favorites: favorites + favorites: favorites, + meshAttached: hasAnyConnected ) enrichedPeers.append(peer) @@ -163,7 +167,8 @@ 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() @@ -171,7 +176,9 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { 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 - let isReachable = now.timeIntervalSince(peerInfo.lastSeen) <= retention + // 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,