mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 14:45:21 +00:00
* Refactor: move Nostr embedding and TLVs out of BLE; add NostrEmbeddedBitChat, Packets, PeerIDUtils; centralize MessageDeduplicator; update ChatViewModel to use new helpers; remove AI_CONTEXT.md and CLAUDE.md * Rename class to BLEService with compatibility alias; move mention parsing out of BLE; emit low-level BLE events to delegate; unify hex helpers; accept 64-hex in isPeerConnected; add PeerIDResolver * Project: rename file to BLEService.swift and update Xcode project; keep typealias SimplifiedBluetoothService = BLEService for compatibility * Remove SimplifiedBluetoothService alias; update app code to use BLEService explicitly * Tests: rename MockSimplifiedBluetoothService to MockBLEService; update typealiases and Xcode project * Docs: update comments to refer to BLEService (tests, protocol, noise service) * Tests: rename SimplifiedBluetoothServiceTests to BLEServiceTests; update project references and class names * Introduce Transport protocol; BLEService conforms; document delegate-only event pattern in BLEService; keep publishers internal for UnifiedPeerService * Adopt Transport end-to-end: add TransportPeerSnapshot + publishers; BLEService maps to Transport snapshots; UnifiedPeerService consumes Transport; ChatViewModel holds Transport * Fix Transport integration: replace getPeerFingerprint with getFingerprint(for:); update PrivateChatManager and CommandProcessor to use Transport; add BLEService.getFingerprint(for:); update PeerManager to use Transport * Refactor transport and BLE/Nostr layers; unify UI events; fix MainActor isolation - Rename SimplifiedBluetoothService to BLEService and slim responsibilities - Introduce Transport protocol and peerEventsDelegate for UI updates - Add NostrTransport and MessageRouter to route PM/read/favorite via BLE or Nostr - Centralize TLVs, PeerID utils, and MessageDeduplicator outside BLE - Update UnifiedPeerService and ChatViewModel to use Transport and delegate events - Fix MainActor isolation: route delegate calls via Task on MainActor; update notifyUI helper - Adjust related files and tests accordingly * BLEService: remove internal publishers; switch to delegate-only events - Drop legacy messages/peers/fullPeers publishers - Provide lightweight peerSnapshotSubject only to satisfy Transport - Rework publishFullPeerData to build snapshots from internal state and notify delegate + subject - Remove all peersPublisher.send call sites - Keep UnifiedPeerService on delegate updates exclusively * Remove inlined Nostr send helpers from ChatViewModel; route via MessageRouter - Replace direct Nostr sends (PM, ACKs, favorites) with MessageRouter - Add router method for delivery ACKs and implement NostrTransport.sendDeliveryAck - Simplify ChatViewModel favorite notification path to use router - Keep Nostr receive handling intact; reduce duplication * Fix ReadReceipt initializer usage in ChatViewModel (readerID + readerNickname) * Fix unused variable warning: replace shadowed 'nostrPubkey' bind with boolean check in ChatViewModel * Fix queued PM format: use TLV for pending messages after Noise handshake - Pending messages (including first-time favorite notifications) now use the same TLV encoding as normal sends - Ensures ChatViewModel can decode on first send, even if handshake completes after queuing --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
308 lines
11 KiB
Swift
308 lines
11 KiB
Swift
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
|
|
let noisePublicKey: Data
|
|
let nickname: String
|
|
let lastSeen: Date
|
|
let isConnected: Bool
|
|
|
|
// Favorite-related properties
|
|
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
|
|
|
|
// Nostr identity (if known)
|
|
var nostrPublicKey: String?
|
|
|
|
// Connection state
|
|
enum ConnectionState {
|
|
case bluetoothConnected
|
|
case nostrAvailable // Mutual favorite, reachable via Nostr
|
|
case offline // Not connected via any transport
|
|
}
|
|
|
|
var connectionState: ConnectionState {
|
|
if isConnected {
|
|
return .bluetoothConnected
|
|
} else if favoriteStatus?.isMutual == true {
|
|
// Mutual favorites can communicate via Nostr when offline
|
|
return .nostrAvailable
|
|
} else {
|
|
return .offline
|
|
}
|
|
}
|
|
|
|
var isFavorite: Bool {
|
|
favoriteStatus?.isFavorite ?? false
|
|
}
|
|
|
|
var isMutualFavorite: Bool {
|
|
favoriteStatus?.isMutual ?? false
|
|
}
|
|
|
|
var theyFavoritedUs: Bool {
|
|
favoriteStatus?.theyFavoritedUs ?? false
|
|
}
|
|
|
|
// Display helpers
|
|
var displayName: String {
|
|
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
|
}
|
|
|
|
var statusIcon: String {
|
|
switch connectionState {
|
|
case .bluetoothConnected:
|
|
return "📻" // Radio icon for mesh connection
|
|
case .nostrAvailable:
|
|
return "🌐" // Purple globe for Nostr
|
|
case .offline:
|
|
if theyFavoritedUs && !isFavorite {
|
|
return "🌙" // Crescent moon - they favorited us but we didn't reciprocate
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize from mesh service data
|
|
init(
|
|
id: String,
|
|
noisePublicKey: Data,
|
|
nickname: String,
|
|
lastSeen: Date = Date(),
|
|
isConnected: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.noisePublicKey = noisePublicKey
|
|
self.nickname = nickname
|
|
self.lastSeen = lastSeen
|
|
self.isConnected = isConnected
|
|
|
|
// Load favorite status - will be set later by the manager
|
|
self.favoriteStatus = nil
|
|
self.nostrPublicKey = nil
|
|
}
|
|
|
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
|
|
// MARK: - Peer Manager
|
|
|
|
/// Manages the collection of peers and their states
|
|
@MainActor
|
|
class PeerManager: ObservableObject {
|
|
@Published var peers: [BitchatPeer] = []
|
|
@Published var favorites: [BitchatPeer] = []
|
|
@Published var mutualFavorites: [BitchatPeer] = []
|
|
|
|
private let meshService: Transport
|
|
private let favoritesService = FavoritesPersistenceService.shared
|
|
|
|
init(meshService: Transport) {
|
|
self.meshService = meshService
|
|
updatePeers()
|
|
|
|
// Listen for updates
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(handleFavoriteChanged),
|
|
name: .favoriteStatusChanged,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@objc private func handleFavoriteChanged() {
|
|
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
|
|
category: SecureLogger.session, level: .debug)
|
|
updatePeers()
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
func updatePeers() {
|
|
// Reduce log verbosity - only log when count changes
|
|
let previousCount = peers.count
|
|
|
|
// Get current mesh peers
|
|
let meshPeers = meshService.getPeerNicknames()
|
|
|
|
// Build peer list
|
|
var allPeers: [BitchatPeer] = []
|
|
var connectedNicknames: Set<String> = []
|
|
var addedPeerIDs: Set<String> = []
|
|
|
|
// Add connected mesh peers (only if actually connected or relay connected)
|
|
for (peerID, nickname) in meshPeers {
|
|
guard let noiseKey = Data(hexString: peerID) else { continue }
|
|
|
|
// Safety check: Never add our own peer ID
|
|
if peerID == meshService.myPeerID {
|
|
continue
|
|
}
|
|
|
|
// Check if this peer is actually connected
|
|
let isConnected = meshService.isPeerConnected(peerID)
|
|
|
|
// Skip disconnected peers unless they're favorites (handled later)
|
|
if !isConnected {
|
|
continue
|
|
}
|
|
|
|
if isConnected {
|
|
connectedNicknames.insert(nickname)
|
|
}
|
|
|
|
// Track that we've added this peer ID
|
|
addedPeerIDs.insert(peerID)
|
|
|
|
var peer = BitchatPeer(
|
|
id: peerID,
|
|
noisePublicKey: noiseKey,
|
|
nickname: nickname,
|
|
isConnected: isConnected
|
|
)
|
|
// Set favorite status - check both by current noise key and by nickname
|
|
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
|
|
peer.favoriteStatus = favoriteStatus
|
|
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
|
} else {
|
|
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
|
|
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
|
|
if let favorite = favoriteByNickname {
|
|
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
|
|
category: SecureLogger.session, level: .info)
|
|
// Update the favorite's noise key to match the current connection
|
|
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
|
|
// Get the updated favorite with the new key
|
|
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
|
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
|
}
|
|
}
|
|
allPeers.append(peer)
|
|
}
|
|
|
|
// Add offline favorites (only those not currently connected AND that we actively favorite)
|
|
|
|
for (favoriteKey, favorite) in favoritesService.favorites {
|
|
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
|
|
|
|
// Skip if this peer is already connected (by nickname)
|
|
if connectedNicknames.contains(favorite.peerNickname) {
|
|
// Skipping favorite - already connected
|
|
continue
|
|
}
|
|
|
|
// Skip if we already added a peer with this ID (prevents duplicates)
|
|
if addedPeerIDs.contains(favoriteID) {
|
|
// Skipping favorite - peer ID already added
|
|
continue
|
|
}
|
|
|
|
// Only add peers that WE favorite (not just ones who favorite us)
|
|
if !favorite.isFavorite {
|
|
// Skipping - we don't favorite them
|
|
continue
|
|
}
|
|
|
|
// Add this favorite as an offline peer
|
|
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
|
|
category: SecureLogger.session, level: .info)
|
|
|
|
var peer = BitchatPeer(
|
|
id: favoriteID,
|
|
noisePublicKey: favorite.peerNoisePublicKey,
|
|
nickname: favorite.peerNickname,
|
|
isConnected: false
|
|
)
|
|
// Set favorite status
|
|
peer.favoriteStatus = favorite
|
|
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
|
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
|
|
allPeers.append(peer)
|
|
}
|
|
|
|
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
|
|
allPeers = allPeers.filter { peer in
|
|
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
|
|
}
|
|
|
|
// Sort: Connected first, then favorites, then alphabetical
|
|
allPeers.sort { lhs, rhs in
|
|
// Direct connections 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
|
|
}
|
|
|
|
// Single pass to compute all subsets and counts
|
|
var favorites: [BitchatPeer] = []
|
|
var mutualFavorites: [BitchatPeer] = []
|
|
var connectedCount = 0
|
|
var offlineCount = 0
|
|
|
|
for peer in allPeers {
|
|
if peer.isFavorite {
|
|
favorites.append(peer)
|
|
}
|
|
if peer.isMutualFavorite {
|
|
mutualFavorites.append(peer)
|
|
}
|
|
if peer.isConnected {
|
|
connectedCount += 1
|
|
} else {
|
|
offlineCount += 1
|
|
}
|
|
}
|
|
|
|
// Final safety check: ensure no duplicate IDs
|
|
var finalPeers: [BitchatPeer] = []
|
|
var seenIDs: Set<String> = []
|
|
for peer in allPeers {
|
|
if !seenIDs.contains(peer.id) {
|
|
seenIDs.insert(peer.id)
|
|
finalPeers.append(peer)
|
|
} else {
|
|
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
|
|
category: SecureLogger.session, level: .warning)
|
|
}
|
|
}
|
|
|
|
self.peers = finalPeers
|
|
self.favorites = favorites
|
|
self.mutualFavorites = mutualFavorites
|
|
|
|
// Log peer list summary sparingly at debug level
|
|
if favoritesService.favorites.count > 0 {
|
|
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
|
|
category: SecureLogger.session, level: .debug)
|
|
} else if previousCount != allPeers.count {
|
|
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
|
|
category: SecureLogger.session, level: .debug)
|
|
}
|
|
}
|
|
|
|
func toggleFavorite(_ peer: BitchatPeer) {
|
|
if peer.isFavorite {
|
|
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
} else {
|
|
favoritesService.addFavorite(
|
|
peerNoisePublicKey: peer.noisePublicKey,
|
|
peerNostrPublicKey: peer.nostrPublicKey,
|
|
peerNickname: peer.nickname
|
|
)
|
|
}
|
|
updatePeers()
|
|
}
|
|
}
|