PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer (#755)

* PeerID 15/n: Bitchat Message & Packet accept in init

* PeerID 16/n: BitchatPeer / PeerInfo / TransportPeer
This commit is contained in:
Islam
2025-10-06 17:17:57 +02:00
committed by GitHub
parent 5f44c56a90
commit 10e3f574ab
9 changed files with 81 additions and 83 deletions
+6 -8
View File
@@ -2,8 +2,8 @@ import Foundation
import CoreBluetooth import CoreBluetooth
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Identifiable, Equatable { struct BitchatPeer: Equatable {
let id: String // Hex-encoded peer ID let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data let noisePublicKey: Data
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Display helpers // Display helpers
var displayName: String { var displayName: String {
nickname.isEmpty ? String(id.prefix(8)) : nickname nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
} }
var statusIcon: String { var statusIcon: String {
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
// Initialize from mesh service data // Initialize from mesh service data
init( init(
id: String, peerID: PeerID,
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false, isConnected: Bool = false,
isReachable: Bool = false isReachable: Bool = false
) { ) {
self.id = id self.peerID = peerID
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
} }
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool { static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
lhs.id == rhs.id lhs.peerID == rhs.peerID
} }
} }
//
+11 -11
View File
@@ -51,7 +51,7 @@ final class BLEService: NSObject {
// 3. Peer Information (single source of truth) // 3. Peer Information (single source of truth)
private struct PeerInfo { private struct PeerInfo {
let id: String let peerID: PeerID
var nickname: String var nickname: String
var isConnected: Bool var isConnected: Bool
var noisePublicKey: Data? var noisePublicKey: Data?
@@ -380,13 +380,13 @@ final class BLEService: NSObject {
collectionsQueue.sync { collectionsQueue.sync {
let snapshot = Array(peers.values) let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve( let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.id, $0.nickname, $0.isConnected) }, snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
selfNickname: myNickname selfNickname: myNickname
) )
return snapshot.map { info in return snapshot.map { info in
TransportPeerSnapshot( TransportPeerSnapshot(
id: info.id, peerID: info.peerID,
nickname: resolvedNames[PeerID(str: info.id)] ?? info.nickname, nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen
@@ -528,7 +528,7 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [PeerID: String] { func getPeerNicknames() -> [PeerID: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
let connected = peers.filter { $0.value.isConnected } 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) return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
} }
} }
@@ -1032,7 +1032,7 @@ extension BLEService {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
if peers[normalizedID] == nil { if peers[normalizedID] == nil {
peers[normalizedID] = PeerInfo( peers[normalizedID] = PeerInfo(
id: normalizedID, peerID: PeerID(str: normalizedID),
nickname: "TestPeer_\(fromPeerID.prefix(4))", nickname: "TestPeer_\(fromPeerID.prefix(4))",
isConnected: true, isConnected: true,
noisePublicKey: packet.senderID, noisePublicKey: packet.senderID,
@@ -2464,7 +2464,7 @@ extension BLEService {
if let existing = existingPeer, existing.isConnected { if let existing = existingPeer, existing.isConnected {
// Update lastSeen and identity info // Update lastSeen and identity info
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: existing.id, peerID: existing.peerID,
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
@@ -2475,7 +2475,7 @@ extension BLEService {
} else { } else {
// New peer or reconnecting peer // New peer or reconnecting peer
peers[peerID] = PeerInfo( peers[peerID] = PeerInfo(
id: peerID, peerID: PeerID(str: peerID),
nickname: announcement.nickname, nickname: announcement.nickname,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
@@ -2599,7 +2599,7 @@ extension BLEService {
accepted = true accepted = true
senderNickname = info.nickname senderNickname = info.nickname
// Handle nickname collisions // 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 { if hasCollision {
senderNickname += "#" + String(peerID.prefix(4)) senderNickname += "#" + String(peerID.prefix(4))
} }
@@ -2917,10 +2917,10 @@ extension BLEService {
return peers.values.map { info in return peers.values.map { info in
var display = info.nickname var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 { if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4)) display += "#" + String(info.peerID.id.prefix(4))
} }
return TransportPeerSnapshot( return TransportPeerSnapshot(
id: info.id, peerID: info.peerID,
nickname: display, nickname: display,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
+1 -1
View File
@@ -4,7 +4,7 @@ import Combine
/// Abstract transport interface used by ChatViewModel and services. /// Abstract transport interface used by ChatViewModel and services.
/// BLEService implements this protocol; a future Nostr transport can too. /// BLEService implements this protocol; a future Nostr transport can too.
struct TransportPeerSnapshot: Equatable, Hashable { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let peerID: PeerID
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
let noisePublicKey: Data? let noisePublicKey: Data?
+14 -14
View File
@@ -77,12 +77,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let favorites = favoritesService.favorites let favorites = favoritesService.favorites
var enrichedPeers: [BitchatPeer] = [] var enrichedPeers: [BitchatPeer] = []
var connected: Set<String> = [] var connected: Set<PeerID> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<PeerID> = []
// Phase 1: Add all mesh peers (connected and reachable) // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers { for peerInfo in meshPeers {
let peerID = peerInfo.id let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh( let peer = buildPeerFromMesh(
@@ -97,13 +97,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Update fingerprint cache // Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey { if let publicKey = peerInfo.noisePublicKey {
fingerprintCache[peerID] = publicKey.sha256Fingerprint() fingerprintCache[peerID.id] = publicKey.sha256Fingerprint()
} }
} }
// Phase 2: Add offline favorites that we actively favorite // Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite { for (favoriteKey, favorite) in favorites where favorite.isFavorite {
let peerID = favoriteKey.hexEncodedString() let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer) // Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue } if addedPeerIDs.contains(peerID) { continue }
@@ -114,12 +114,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
} }
if isConnectedByNickname { continue } if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID) let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID.id)
enrichedPeers.append(peer) enrichedPeers.append(peer)
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint() fingerprintCache[peerID.id] = favoriteKey.sha256Fingerprint()
} }
// Phase 3: Sort peers // Phase 3: Sort peers
@@ -140,7 +140,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var newIndex: [String: BitchatPeer] = [:] var newIndex: [String: BitchatPeer] = [:]
for peer in enrichedPeers { for peer in enrichedPeers {
newIndex[peer.id] = peer newIndex[peer.peerID.id] = peer
if peer.isFavorite { if peer.isFavorite {
favoritesList.append(peer) favoritesList.append(peer)
@@ -155,7 +155,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
p.isConnected || p.isReachable || p.isMutualFavorite p.isConnected || p.isReachable || p.isMutualFavorite
} }
self.peers = filtered self.peers = filtered
self.connectedPeerIDs = connected self.connectedPeerIDs = Set(connected.map(\.id))
self.favorites = favoritesList self.favorites = favoritesList
self.mutualFavorites = mutualsList self.mutualFavorites = mutualsList
self.peerIndex = newIndex self.peerIndex = newIndex
@@ -184,7 +184,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached) let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerInfo.id, peerID: peerInfo.peerID,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
@@ -207,7 +207,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
peerID: String peerID: String
) -> BitchatPeer { ) -> BitchatPeer {
var peer = BitchatPeer( var peer = BitchatPeer(
id: peerID, peerID: PeerID(str: peerID),
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
@@ -232,7 +232,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
func getPeerID(for nickname: String) -> String? { func getPeerID(for nickname: String) -> String? {
for peer in peers { for peer in peers {
if peer.displayName == nickname || peer.nickname == nickname { if peer.displayName == nickname || peer.nickname == nickname {
return peer.id return peer.peerID.id
} }
} }
return nil return nil
@@ -380,11 +380,11 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var allPeers: [BitchatPeer] { peers } var allPeers: [BitchatPeer] { peers }
var connectedPeers: [String] { Array(connectedPeerIDs) } var connectedPeers: [String] { Array(connectedPeerIDs) }
var favoritePeers: Set<String> { var favoritePeers: Set<String> {
Set(favorites.compactMap { getFingerprint(for: $0.id) }) Set(favorites.compactMap { getFingerprint(for: $0.peerID.id) })
} }
var blockedUsers: Set<String> { var blockedUsers: Set<String> {
Set(peers.compactMap { peer in Set(peers.compactMap { peer in
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil isBlocked(peer.peerID.id) ? getFingerprint(for: peer.peerID.id) : nil
}) })
} }
} }
+4 -4
View File
@@ -4,10 +4,10 @@ import Foundation
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur. /// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters: /// - 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. /// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName. /// - 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 // Count collisions among connected peers and include our own nickname
var counts: [String: Int] = [:] var counts: [String: Int] = [:]
for p in peers where p.isConnected { for p in peers where p.isConnected {
@@ -19,9 +19,9 @@ struct PeerDisplayNameResolver {
for p in peers { for p in peers {
var name = p.nickname var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 { 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 return result
} }
+20 -20
View File
@@ -323,7 +323,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> String? { func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> String? {
// Check known peers for a noise key match // Check known peers for a noise key match
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) { if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
return match.id return match.peerID.id
} }
// Also search cache mapping // Also search cache mapping
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
@@ -586,10 +586,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var uniquePeers: [String: BitchatPeer] = [:] var uniquePeers: [String: BitchatPeer] = [:]
for peer in peers { for peer in peers {
// Keep the first occurrence of each peer ID // Keep the first occurrence of each peer ID
if uniquePeers[peer.id] == nil { if uniquePeers[peer.peerID.id] == nil {
uniquePeers[peer.id] = peer uniquePeers[peer.peerID.id] = peer
} else { } 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 self.peerIndex = uniquePeers
@@ -1220,11 +1220,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Find the ephemeral peer ID for this Noise key // Find the ephemeral peer ID for this Noise key
let ephemeralPeerID = unifiedPeerService.peers.first { peer in let ephemeralPeerID = unifiedPeerService.peers.first { peer in
peer.noisePublicKey == noisePublicKey peer.noisePublicKey == noisePublicKey
}?.id }?.peerID
if let ephemeralID = ephemeralPeerID { if let ephemeralID = ephemeralPeerID {
// Found the ephemeral peer, use normal toggle // Found the ephemeral peer, use normal toggle
unifiedPeerService.toggleFavorite(ephemeralID) unifiedPeerService.toggleFavorite(ephemeralID.id)
// Also trigger UI update // Also trigger UI update
objectWillChange.send() objectWillChange.send()
} else { } else {
@@ -3936,8 +3936,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Build current peer->seed map (excluding self) // Build current peer->seed map (excluding self)
let myID = meshService.myPeerID let myID = meshService.myPeerID
var currentSeeds: [String: String] = [:] var currentSeeds: [String: String] = [:]
for p in allPeers where p.id != myID { for p in allPeers where p.peerID != myID {
currentSeeds[p.id] = meshSeed(for: p.id) currentSeeds[p.peerID.id] = meshSeed(for: p.peerID.id)
} }
// If seeds unchanged and palette exists for both themes, skip // If seeds unchanged and palette exists for both themes, skip
if currentSeeds == peerPaletteSeeds, if currentSeeds == peerPaletteSeeds,
@@ -4322,7 +4322,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Also log any offline favorites and whether we consider them verified // Also log any offline favorites and whether we consider them verified
let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected } let offlineFavorites = unifiedPeerService.favorites.filter { !$0.isConnected }
for fav in offlineFavorites { 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 isVer = fp.flatMap { verifiedFingerprints.contains($0) } ?? false
let fpShort = fp?.prefix(8) ?? "nil" let fpShort = fp?.prefix(8) ?? "nil"
SecureLogger.info("⭐️ Favorite offline: \(fav.nickname) fp=\(fpShort) verified=\(isVer)", category: .security) 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 { guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else {
return false 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 we already have a pending verification with this peer, don't send another
if pendingQRVerifications[peerID] != nil { if pendingQRVerifications[peerID.id] != nil {
return true return true
} }
// Generate nonceA // Generate nonceA
var nonce = Data(count: 16) var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) } _ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
var pending = PendingVerification(noiseKeyHex: qr.noiseKeyHex, signKeyHex: qr.signKeyHex, nonceA: nonce, startedAt: Date(), sent: false) 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 // If Noise session is established, send immediately; otherwise trigger handshake and send on auth
let noise = meshService.getNoiseService() let noise = meshService.getNoiseService()
if noise.hasEstablishedSession(with: PeerID(str: peerID)) { if noise.hasEstablishedSession(with: peerID) {
meshService.sendVerifyChallenge(to: PeerID(str: peerID), noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true pending.sent = true
pendingQRVerifications[peerID] = pending pendingQRVerifications[peerID.id] = pending
} else { } else {
meshService.triggerHandshake(with: PeerID(str: peerID)) meshService.triggerHandshake(with: peerID)
} }
return true 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 /// Clean up stale unread peer IDs that no longer exist in the peer list
@MainActor @MainActor
private func cleanupStaleUnreadPeerIDs() { 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) let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs)
if !staleIDs.isEmpty { if !staleIDs.isEmpty {
@@ -5253,7 +5253,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) { private func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: String, key: Data?) {
guard let key, 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 ephemeralPeerID != targetPeerID
else { else {
return return
@@ -5290,7 +5290,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) { private func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: String, key: Data?, senderPubkey: String) {
unreadPrivateMessages.remove(targetPeerID) unreadPrivateMessages.remove(targetPeerID)
if let key, 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) unreadPrivateMessages.remove(ephemeralPeerID)
} }
if !sentReadReceipts.contains(message.id) { if !sentReadReceipts.contains(message.id) {
@@ -5322,7 +5322,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
unreadPrivateMessages.insert(targetPeerID) unreadPrivateMessages.insert(targetPeerID)
if let key, 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 { ephemeralPeerID != targetPeerID {
unreadPrivateMessages.insert(ephemeralPeerID) unreadPrivateMessages.insert(ephemeralPeerID)
} }
+2 -2
View File
@@ -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() Spacer()
@@ -1085,7 +1085,7 @@ struct ContentView: View {
return (n, n > 0 ? standardGreen : Color.secondary) return (n, n > 0 ? standardGreen : Color.secondary)
case .mesh: case .mesh:
let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in 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 } if peer.isConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isReachable { counts.others += 1 } else if peer.isReachable { counts.others += 1 }
} }
+1 -1
View File
@@ -443,7 +443,7 @@ struct LocationChannelsSheet: View {
// Count mesh-connected OR mesh-reachable peers (exclude self) // Count mesh-connected OR mesh-reachable peers (exclude self)
let myID = viewModel.meshService.myPeerID let myID = viewModel.meshService.myPeerID
return viewModel.allPeers.reduce(0) { acc, peer in 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 return acc
} }
} }
+22 -22
View File
@@ -18,6 +18,20 @@ struct MeshPeerList: View {
} }
var body: some 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 { if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby) Text(Strings.noneNearby)
@@ -27,27 +41,13 @@ struct MeshPeerList: View {
.padding(.top, 12) .padding(.top, 12)
} }
} else { } 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) { VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx] let item = peers[idx]
let peer = item.peer let peer = item.peer
let isMe = item.isMe let isMe = item.isMe
HStack(spacing: 4) { HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill") Image(systemName: "person.fill")
@@ -89,7 +89,7 @@ struct MeshPeerList: View {
} }
} }
if !isMe, viewModel.isPeerBlocked(peer.id) { if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
Image(systemName: "nosign") Image(systemName: "nosign")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.red) .foregroundColor(.red)
@@ -105,7 +105,7 @@ struct MeshPeerList: View {
} }
} else { } else {
// Offline: prefer showing verified badge from persisted fingerprints // Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.id), if let fp = viewModel.getFingerprint(for: peer.peerID.id),
viewModel.verifiedFingerprints.contains(fp) { viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill") Image(systemName: "checkmark.seal.fill")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
@@ -130,7 +130,7 @@ struct MeshPeerList: View {
} }
if !isMe { if !isMe {
Button(action: { onToggleFavorite(peer.id) }) { Button(action: { onToggleFavorite(peer.peerID.id) }) {
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star") Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor) .foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
@@ -142,16 +142,16 @@ struct MeshPeerList: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } } .onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } } .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
} }
} }
// Seed and update order outside result builder // Seed and update order outside result builder
.onAppear { .onAppear {
let currentIDs = mapped.map { $0.peer.id } let currentIDs = mapped.map { $0.peer.peerID.id }
orderedIDs = currentIDs orderedIDs = currentIDs
} }
.onChange(of: mapped.map { $0.peer.id }) { ids in .onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
var newOrder = orderedIDs var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) } newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) } for id in ids where !newOrder.contains(id) { newOrder.append(id) }