From 10e3f574abf150db9808c23f26e4d440132efb75 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:17:57 +0100 Subject: [PATCH] PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer (#755) * PeerID 15/n: Bitchat Message & Packet accept in init * PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer --- bitchat/Models/BitchatPeer.swift | 14 +++---- bitchat/Services/BLEService.swift | 22 +++++------ bitchat/Services/Transport.swift | 2 +- bitchat/Services/UnifiedPeerService.swift | 28 ++++++------- bitchat/Utils/PeerDisplayNameResolver.swift | 8 ++-- bitchat/ViewModels/ChatViewModel.swift | 40 +++++++++---------- bitchat/Views/ContentView.swift | 4 +- bitchat/Views/LocationChannelsSheet.swift | 2 +- bitchat/Views/MeshPeerList.swift | 44 ++++++++++----------- 9 files changed, 81 insertions(+), 83 deletions(-) diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index e8f82df0..468d962c 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -2,8 +2,8 @@ import Foundation import CoreBluetooth /// Represents a peer in the BitChat network with all associated metadata -struct BitchatPeer: Identifiable, Equatable { - let id: String // Hex-encoded peer ID +struct BitchatPeer: Equatable { + let peerID: PeerID // Hex-encoded peer ID let noisePublicKey: Data let nickname: String let lastSeen: Date @@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable { // Display helpers var displayName: String { - nickname.isEmpty ? String(id.prefix(8)) : nickname + nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname } var statusIcon: String { @@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable { // Initialize from mesh service data init( - id: String, + peerID: PeerID, noisePublicKey: Data, nickname: String, lastSeen: Date = Date(), isConnected: Bool = false, isReachable: Bool = false ) { - self.id = id + self.peerID = peerID self.noisePublicKey = noisePublicKey self.nickname = nickname self.lastSeen = lastSeen @@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable { } static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { - lhs.id == rhs.id + lhs.peerID == rhs.peerID } } - -// diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 7ccc3334..202c7a00 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -51,7 +51,7 @@ final class BLEService: NSObject { // 3. Peer Information (single source of truth) private struct PeerInfo { - let id: String + let peerID: PeerID var nickname: String var isConnected: Bool var noisePublicKey: Data? @@ -380,13 +380,13 @@ final class BLEService: NSObject { collectionsQueue.sync { let snapshot = Array(peers.values) let resolvedNames = PeerDisplayNameResolver.resolve( - snapshot.map { ($0.id, $0.nickname, $0.isConnected) }, + snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) }, selfNickname: myNickname ) return snapshot.map { info in TransportPeerSnapshot( - id: info.id, - nickname: resolvedNames[PeerID(str: info.id)] ?? info.nickname, + peerID: info.peerID, + nickname: resolvedNames[info.peerID] ?? info.nickname, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, lastSeen: info.lastSeen @@ -528,7 +528,7 @@ final class BLEService: NSObject { func getPeerNicknames() -> [PeerID: String] { return collectionsQueue.sync { let connected = peers.filter { $0.value.isConnected } - let tuples = connected.map { ($0.key, $0.value.nickname, true) } + let tuples = connected.map { (PeerID(str: $0.key), $0.value.nickname, true) } return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname) } } @@ -1032,7 +1032,7 @@ extension BLEService { collectionsQueue.sync(flags: .barrier) { if peers[normalizedID] == nil { peers[normalizedID] = PeerInfo( - id: normalizedID, + peerID: PeerID(str: normalizedID), nickname: "TestPeer_\(fromPeerID.prefix(4))", isConnected: true, noisePublicKey: packet.senderID, @@ -2464,7 +2464,7 @@ extension BLEService { if let existing = existingPeer, existing.isConnected { // Update lastSeen and identity info peers[peerID] = PeerInfo( - id: existing.id, + peerID: existing.peerID, nickname: announcement.nickname, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, @@ -2475,7 +2475,7 @@ extension BLEService { } else { // New peer or reconnecting peer peers[peerID] = PeerInfo( - id: peerID, + peerID: PeerID(str: peerID), nickname: announcement.nickname, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, @@ -2599,7 +2599,7 @@ extension BLEService { 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) + let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID } || (myNickname == info.nickname) if hasCollision { senderNickname += "#" + String(peerID.prefix(4)) } @@ -2917,10 +2917,10 @@ extension BLEService { return peers.values.map { info in var display = info.nickname if info.isConnected, (counts[info.nickname] ?? 0) > 1 { - display += "#" + String(info.id.prefix(4)) + display += "#" + String(info.peerID.id.prefix(4)) } return TransportPeerSnapshot( - id: info.id, + peerID: info.peerID, nickname: display, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 1f47f38e..502d33ba 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -4,7 +4,7 @@ import Combine /// Abstract transport interface used by ChatViewModel and services. /// BLEService implements this protocol; a future Nostr transport can too. struct TransportPeerSnapshot: Equatable, Hashable { - let id: String + let peerID: PeerID let nickname: String let isConnected: Bool let noisePublicKey: Data? diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index a52b8fbc..a763b95c 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -77,12 +77,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { let favorites = favoritesService.favorites var enrichedPeers: [BitchatPeer] = [] - var connected: Set = [] - var addedPeerIDs: Set = [] + var connected: Set = [] + var addedPeerIDs: Set = [] // Phase 1: Add all mesh peers (connected and reachable) for peerInfo in meshPeers { - let peerID = peerInfo.id + let peerID = peerInfo.peerID guard peerID != meshService.myPeerID else { continue } // Never add self let peer = buildPeerFromMesh( @@ -97,13 +97,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // Update fingerprint cache if let publicKey = peerInfo.noisePublicKey { - fingerprintCache[peerID] = publicKey.sha256Fingerprint() + fingerprintCache[peerID.id] = publicKey.sha256Fingerprint() } } // Phase 2: Add offline favorites that we actively favorite for (favoriteKey, favorite) in favorites where favorite.isFavorite { - let peerID = favoriteKey.hexEncodedString() + let peerID = PeerID(hexData: favoriteKey) // Skip if already added (connected peer) if addedPeerIDs.contains(peerID) { continue } @@ -114,12 +114,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } if isConnectedByNickname { continue } - let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) + let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID.id) enrichedPeers.append(peer) addedPeerIDs.insert(peerID) // Update fingerprint cache - fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() + fingerprintCache[peerID.id] = favoriteKey.sha256Fingerprint() } // Phase 3: Sort peers @@ -140,7 +140,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var newIndex: [String: BitchatPeer] = [:] for peer in enrichedPeers { - newIndex[peer.id] = peer + newIndex[peer.peerID.id] = peer if peer.isFavorite { favoritesList.append(peer) @@ -155,7 +155,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { p.isConnected || p.isReachable || p.isMutualFavorite } self.peers = filtered - self.connectedPeerIDs = connected + self.connectedPeerIDs = Set(connected.map(\.id)) self.favorites = favoritesList self.mutualFavorites = mutualsList self.peerIndex = newIndex @@ -184,7 +184,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached) var peer = BitchatPeer( - id: peerInfo.id, + peerID: peerInfo.peerID, noisePublicKey: peerInfo.noisePublicKey ?? Data(), nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, @@ -207,7 +207,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { peerID: String ) -> BitchatPeer { var peer = BitchatPeer( - id: peerID, + peerID: PeerID(str: peerID), noisePublicKey: favorite.peerNoisePublicKey, nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, @@ -232,7 +232,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { func getPeerID(for nickname: String) -> String? { for peer in peers { if peer.displayName == nickname || peer.nickname == nickname { - return peer.id + return peer.peerID.id } } return nil @@ -380,11 +380,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { var allPeers: [BitchatPeer] { peers } var connectedPeers: [String] { Array(connectedPeerIDs) } var favoritePeers: Set { - Set(favorites.compactMap { getFingerprint(for: $0.id) }) + Set(favorites.compactMap { getFingerprint(for: $0.peerID.id) }) } var blockedUsers: Set { Set(peers.compactMap { peer in - isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil + isBlocked(peer.peerID.id) ? getFingerprint(for: peer.peerID.id) : nil }) } } diff --git a/bitchat/Utils/PeerDisplayNameResolver.swift b/bitchat/Utils/PeerDisplayNameResolver.swift index 6511e109..95121df7 100644 --- a/bitchat/Utils/PeerDisplayNameResolver.swift +++ b/bitchat/Utils/PeerDisplayNameResolver.swift @@ -4,10 +4,10 @@ import Foundation struct PeerDisplayNameResolver { /// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur. /// - Parameters: - /// - peers: Array of tuples (id, nickname, isConnected). + /// - peers: Array of tuples (peerID, nickname, isConnected). /// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it. /// - Returns: Map of peerID -> displayName. - static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] { + static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] { // Count collisions among connected peers and include our own nickname var counts: [String: Int] = [:] for p in peers where p.isConnected { @@ -19,9 +19,9 @@ struct PeerDisplayNameResolver { for p in peers { var name = p.nickname if p.isConnected, (counts[p.nickname] ?? 0) > 1 { - name += "#" + String(p.id.prefix(4)) + name += "#" + String(p.peerID.id.prefix(4)) } - result[PeerID(str: p.id)] = name + result[p.peerID] = name } return result } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index aa793bed..8955181f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -323,7 +323,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> String? { // Check known peers for a noise key match if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) { - return match.id + return match.peerID.id } // Also search cache mapping if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { @@ -586,10 +586,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { var uniquePeers: [String: BitchatPeer] = [:] for peer in peers { // Keep the first occurrence of each peer ID - if uniquePeers[peer.id] == nil { - uniquePeers[peer.id] = peer + if uniquePeers[peer.peerID.id] == nil { + uniquePeers[peer.peerID.id] = peer } else { - SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.id) (\(peer.displayName))", category: .session) + SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", category: .session) } } self.peerIndex = uniquePeers @@ -1220,11 +1220,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Find the ephemeral peer ID for this Noise key let ephemeralPeerID = unifiedPeerService.peers.first { peer in peer.noisePublicKey == noisePublicKey - }?.id + }?.peerID if let ephemeralID = ephemeralPeerID { // Found the ephemeral peer, use normal toggle - unifiedPeerService.toggleFavorite(ephemeralID) + unifiedPeerService.toggleFavorite(ephemeralID.id) // Also trigger UI update objectWillChange.send() } else { @@ -3936,8 +3936,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // 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) + for p in allPeers where p.peerID != myID { + currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id) } // If seeds unchanged and palette exists for both themes, skip if currentSeeds == peerPaletteSeeds, @@ -4322,7 +4322,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Also log any offline favorites and whether we consider them verified let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } for fav in offlineFavorites { - let fp = unifiedPeerService.getFingerprint(for: fav.id) + let fp = unifiedPeerService.getFingerprint(for: fav.peerID.id) let isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false let fpShort = fp?.prefix(8) ?? "nil" SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security) @@ -4574,24 +4574,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { return false } - let peerID = peer.id + let peerID = peer.peerID // If we already have a pending verification with this peer, don't send another - if pendingQRVerifications[peerID] != nil { + if pendingQRVerifications[peerID.id] != nil { return true } // Generate nonceA var nonce = Data(count: 16) _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false) - pendingQRVerifications[peerID] = pending + pendingQRVerifications[peerID.id] = pending // If Noise session is established, send immediately; otherwise trigger handshake and send on auth let noise = meshService.getNoiseService() - if noise.hasEstablishedSession(with: PeerID(str: peerID)) { - meshService.sendVerifyChallenge(to: PeerID(str: peerID), noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + if noise.hasEstablishedSession(with: peerID) { + meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) pending.sent = true - pendingQRVerifications[peerID] = pending + pendingQRVerifications[peerID.id] = pending } else { - meshService.triggerHandshake(with: PeerID(str: peerID)) + meshService.triggerHandshake(with: peerID) } return true } @@ -4766,7 +4766,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { /// Clean up stale unread peer IDs that no longer exist in the peer list @MainActor private func cleanupStaleUnreadPeerIDs() { - let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.id }) + let currentPeerIDs = Set(unifiedPeerService.peers.map { $0.peerID.id }) let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) if !staleIDs.isEmpty { @@ -5253,7 +5253,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) { guard let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, ephemeralPeerID != targetPeerID else { return @@ -5290,7 +5290,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) { unreadPrivateMessages.remove(targetPeerID) if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id { + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id { unreadPrivateMessages.remove(ephemeralPeerID) } if !sentReadReceipts.contains(message.id) { @@ -5322,7 +5322,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { unreadPrivateMessages.insert(targetPeerID) if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id, + let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID.id, ephemeralPeerID != targetPeerID { unreadPrivateMessages.insert(ephemeralPeerID) } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 5780fc21..9f3a66dd 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -989,7 +989,7 @@ struct ContentView: View { } } } - .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) + .id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined()) } Spacer() @@ -1085,7 +1085,7 @@ struct ContentView: View { return (n, n > 0 ? standardGreen : Color.secondary) case .mesh: let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in - guard peer.id != viewModel.meshService.myPeerID else { return } + guard peer.peerID != viewModel.meshService.myPeerID else { return } if peer.isConnected { counts.mesh += 1; counts.others += 1 } else if peer.isReachable { counts.others += 1 } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index db27f53a..fa282b72 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -443,7 +443,7 @@ struct LocationChannelsSheet: View { // 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 || peer.isReachable) { return acc + 1 } + if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } return acc } } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index a7d05baa..c82dff17 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -18,6 +18,20 @@ struct MeshPeerList: View { } var body: some View { + let myPeerID = viewModel.meshService.myPeerID + let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in + let isMe = peer.peerID == myPeerID + let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id) + let enc = viewModel.getEncryptionStatus(for: peer.peerID.id) + return (peer, isMe, hasUnread, enc) + } + // Stable visual order without mutating state here + let currentIDs = mapped.map { $0.peer.peerID.id } + let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } + let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in + mapped.first(where: { $0.peer.peerID.id == id }) + } + if viewModel.allPeers.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) @@ -27,27 +41,13 @@ struct MeshPeerList: View { .padding(.top, 12) } } else { - let myPeerID = viewModel.meshService.myPeerID - let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in - let isMe = peer.id == myPeerID - let hasUnread = viewModel.hasUnreadMessages(for: peer.id) - let enc = viewModel.getEncryptionStatus(for: peer.id) - return (peer, isMe, hasUnread, enc) - } - // Stable visual order without mutating state here - let currentIDs = mapped.map { $0.peer.id } - let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } - let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in - mapped.first(where: { $0.peer.id == id }) - } - VStack(alignment: .leading, spacing: 0) { ForEach(0..