Files
bitchat/bitchat/Services/UnifiedPeerService.swift
T
Mateusz MatoszkoandGitHub 7836daa6d3 Adds O(1) method for peer nickname retrieval (#450)
* Adds O(1) method for peerNickname retrieval

* Uses peerNickname method where possible
2025-08-17 21:29:15 +02:00

404 lines
14 KiB
Swift

//
// UnifiedPeerService.swift
// bitchat
//
// Unified peer state management combining mesh connectivity and favorites
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Combine
import SwiftUI
import CryptoKit
/// Single source of truth for peer state, combining mesh connectivity and favorites
@MainActor
class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Published Properties
@Published private(set) var peers: [BitchatPeer] = []
@Published private(set) var connectedPeerIDs: Set<String> = []
@Published private(set) var favorites: [BitchatPeer] = []
@Published private(set) var mutualFavorites: [BitchatPeer] = []
// MARK: - Private Properties
private var peerIndex: [String: BitchatPeer] = [:]
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(meshService: Transport) {
self.meshService = meshService
// Subscribe to changes from both services
setupSubscriptions()
// Perform initial update
Task { @MainActor in
updatePeers()
}
}
// MARK: - Setup
private func setupSubscriptions() {
// Subscribe to mesh peer updates via delegate (preferred over publishers)
meshService.peerEventsDelegate = self
// Also listen for favorite change notifications
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.updatePeers()
}
.store(in: &cancellables)
}
// TransportPeerEventsDelegate
func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) {
updatePeers()
}
// MARK: - Core Update Logic
private func updatePeers() {
let meshPeers = meshService.currentPeerSnapshots()
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 {
let peerID = peerInfo.id
guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh(
peerInfo: peerInfo,
favorites: favorites
)
enrichedPeers.append(peer)
connected.insert(peerID)
addedPeerIDs.insert(peerID)
// Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey {
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
}
}
// Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
let peerID = favoriteKey.hexEncodedString()
// Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue }
// Skip if connected under different ID but same nickname
let isConnectedByNickname = enrichedPeers.contains {
$0.nickname == favorite.peerNickname && $0.isConnected
}
if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
enrichedPeers.append(peer)
addedPeerIDs.insert(peerID)
// Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
}
// 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
}
// Finally alphabetical
return lhs.displayName < rhs.displayName
}
// Phase 4: Build subsets and indices
var favoritesList: [BitchatPeer] = []
var mutualsList: [BitchatPeer] = []
var newIndex: [String: BitchatPeer] = [:]
for peer in enrichedPeers {
newIndex[peer.id] = peer
if peer.isFavorite {
favoritesList.append(peer)
}
if peer.isMutualFavorite {
mutualsList.append(peer)
}
}
// Phase 5: Update published properties
self.peers = enrichedPeers
self.connectedPeerIDs = connected
self.favorites = favoritesList
self.mutualFavorites = mutualsList
self.peerIndex = newIndex
// Log summary (commented out to reduce noise)
// let connectedCount = connected.count
// let offlineCount = enrichedPeers.count - connectedCount
// Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline)
}
// MARK: - Peer Building Helpers
private func buildPeerFromMesh(
peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
) -> BitchatPeer {
var peer = BitchatPeer(
id: peerInfo.id,
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen,
isConnected: true
)
// Check for favorite status
if let noiseKey = peerInfo.noisePublicKey,
let favoriteStatus = favorites[noiseKey] {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check by nickname for reconnected peers
let favoriteByNickname = favorites.values.first {
$0.peerNickname == peerInfo.nickname
}
if let favorite = favoriteByNickname,
let noiseKey = peerInfo.noisePublicKey {
SecureLogger.log(
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
category: SecureLogger.session,
level: .debug
)
// Update the favorite's key in persistence
favoritesService.updateNoisePublicKey(
from: favorite.peerNoisePublicKey,
to: noiseKey,
peerNickname: peerInfo.nickname
)
// Get updated favorite
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
return peer
}
private func buildPeerFromFavorite(
favorite: FavoritesPersistenceService.FavoriteRelationship,
peerID: String
) -> BitchatPeer {
var peer = BitchatPeer(
id: peerID,
noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated,
isConnected: false
)
peer.favoriteStatus = favorite
peer.nostrPublicKey = favorite.peerNostrPublicKey
return peer
}
// MARK: - Public Methods
/// Get peer by ID
func getPeer(by id: String) -> BitchatPeer? {
return peerIndex[id]
}
/// Get peer ID for nickname
func getPeerID(for nickname: String) -> String? {
for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname {
return peer.id
}
}
return nil
}
/// Check if peer is online
func isOnline(_ peerID: String) -> Bool {
return connectedPeerIDs.contains(peerID)
}
/// Check if peer is blocked
func isBlocked(_ peerID: String) -> Bool {
// Get fingerprint
guard let fingerprint = getFingerprint(for: peerID) else { return false }
// Check SecureIdentityStateManager for block status
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
return identity.isBlocked
}
return false
}
/// Toggle favorite status
func toggleFavorite(_ peerID: String) {
guard let peer = getPeer(by: peerID) else {
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)",
category: SecureLogger.session, level: .warning)
return
}
let wasFavorite = peer.isFavorite
// Get the actual nickname for logging and saving
var actualNickname = peer.nickname
// Debug logging to understand the issue
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
category: SecureLogger.session, level: .debug)
if actualNickname.isEmpty {
// Try to get from mesh service's current peer list
if let meshPeerNickname = meshService.peerNickname(peerID: peerID) {
actualNickname = meshPeerNickname
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'",
category: SecureLogger.session, level: .debug)
}
}
// Use displayName as fallback (which shows ID prefix if nickname is empty)
let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname
if wasFavorite {
// Remove favorite
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
} else {
// Get or derive peer's Nostr public key if not already known
var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
}
// Add favorite
favoritesService.addFavorite(
peerNoisePublicKey: peer.noisePublicKey,
peerNostrPublicKey: peerNostrKey,
peerNickname: finalNickname
)
}
// Log the final nickname being saved
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
category: SecureLogger.session, level: .debug)
// Send favorite notification to the peer
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
// Force update of peers to reflect the change
updatePeers()
// Force UI update by notifying SwiftUI directly
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
/// Toggle blocked status
func toggleBlocked(_ peerID: String) {
guard let fingerprint = getFingerprint(for: peerID) else { return }
// Get or create social identity
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint)
?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
// Toggle blocked status
identity.isBlocked = !identity.isBlocked
// Can't be both favorite and blocked
if identity.isBlocked {
identity.isFavorite = false
// Also remove from favorites service
if let peer = getPeer(by: peerID) {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
}
}
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
}
/// Get fingerprint for peer ID
func getFingerprint(for peerID: String) -> String? {
// Check cache first
if let cached = fingerprintCache[peerID] {
return cached
}
// Try to get from mesh service
if let fingerprint = meshService.getFingerprint(for: peerID) {
fingerprintCache[peerID] = fingerprint
return fingerprint
}
// Try to get from peer's public key
if let peer = getPeer(by: peerID) {
let fingerprint = peer.noisePublicKey.sha256Fingerprint()
fingerprintCache[peerID] = fingerprint
return fingerprint
}
return nil
}
// MARK: - Compatibility Methods (for easy migration)
var allPeers: [BitchatPeer] { peers }
var connectedPeers: [String] { Array(connectedPeerIDs) }
var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.id) })
}
var blockedUsers: Set<String> {
Set(peers.compactMap { peer in
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
})
}
}
// MARK: - Helper Extensions
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}