refactor(init): add dependency-injected initializer for ChatViewModel to enable composition/testing

Provide an alternate initializer that accepts meshService, router, privateChatManager, unifiedPeerService, commandProcessor, and an optional ReadReceiptTracker. Keeps default init intact; both perform the same wiring and startup behavior.
This commit is contained in:
jack
2025-08-26 20:11:59 +02:00
parent dd4e2f6e86
commit 6533629362
5 changed files with 200 additions and 10 deletions
+3 -4
View File
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if peerID.count == 64, let key = Data(hexString: peerID) {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -664,8 +664,7 @@ final class BLEService: NSObject {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
return publicKey.sha256Fingerprint()
}
return nil
}
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes)
guard peerID.count == 16 else { return nil }
guard PeerIDResolver.isShortID(peerID) else { return nil }
for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.count == 16,
if PeerIDResolver.isShortID(peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
+193 -2
View File
@@ -245,7 +245,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
private let commandProcessor: CommandProcessor
private let messageRouter: MessageRouter
private let privateChatManager: PrivateChatManager
private let readReceiptTracker = ReadReceiptTracker()
private let readReceiptTracker: ReadReceiptTracker
private let unifiedPeerService: UnifiedPeerService
private let autocompleteService: AutocompleteService
@@ -456,6 +456,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
init() {
// Initialize read receipt tracker first
self.readReceiptTracker = ReadReceiptTracker()
// Initialize services
self.commandProcessor = CommandProcessor()
self.privateChatManager = PrivateChatManager(meshService: meshService, readReceiptTracker: readReceiptTracker)
@@ -689,6 +691,195 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
)
#endif
}
// MARK: - Dependency-injected initializer (for testing and composition)
@MainActor
init(meshService: Transport,
commandProcessor: CommandProcessor,
privateChatManager: PrivateChatManager,
unifiedPeerService: UnifiedPeerService,
messageRouter: MessageRouter,
readReceiptTracker: ReadReceiptTracker = ReadReceiptTracker()) {
// Assign dependencies
self.meshService = meshService
self.commandProcessor = commandProcessor
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.messageRouter = messageRouter
self.readReceiptTracker = readReceiptTracker
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.privateChatManager.messageRouter = self.messageRouter
self.commandProcessor.meshService = meshService
// Subscribe to privateChatManager changes to trigger UI updates
privateChatManager.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
loadNickname()
loadVerifiedFingerprints()
meshService.delegate = self
// Log fingerprint after a delay to ensure encryption service is ready
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak self] in
if let self = self {
_ = self.getMyFingerprint()
}
}
// Set nickname before starting services
meshService.setNickname(nickname)
// Start mesh service immediately
meshService.startServices()
// Initialize Nostr services
Task { @MainActor in
nostrRelayManager = NostrRelayManager.shared
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
nostrRelayManager?.connect()
// Small delay to ensure read receipts are fully loaded
try? await Task.sleep(nanoseconds: TransportConfig.uiStartupShortSleepNs)
// Set up Nostr message handling directly
setupNostrMessageHandling()
// Attempt to flush any queued outbox after Nostr comes online
messageRouter.flushAllOutbox()
// End startup phase after 2 seconds
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000))
self.isStartupPhase = false
}
// Bind unified peer service's peer list to our published property
let cancellable = unifiedPeerService.$peers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
// Update peers on main thread for UI consistency
guard let self = self else { return }
Task { @MainActor in
self.allPeers = peers
// Build a quick index for getPeer(by:)
var uniquePeers: [String: BitchatPeer] = [:]
for peer in peers {
if uniquePeers[peer.id] == nil {
uniquePeers[peer.id] = peer
}
}
self.peerIndex = uniquePeers
if self.selectedPrivateChatFingerprint != nil {
self.updatePrivateChatPeerIfNeeded()
}
}
}
self.cancellables.insert(cancellable)
// Resubscribe geohash on relay reconnect
if let relayMgr = self.nostrRelayManager {
relayMgr.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] connected in
guard let self = self else { return }
if connected {
Task { @MainActor in
self.resubscribeCurrentGeohash()
}
}
}
.store(in: &self.cancellables)
}
}
// Set up Noise encryption callbacks
setupNoiseCallbacks()
// Observe location channel selection
LocationChannelManager.shared.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
guard let self = self else { return }
Task { @MainActor in
self.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
// Initialize with current selection
Task { @MainActor in
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
}
// Request notification permission
NotificationService.shared.requestAuthorization()
// Listen for favorite status changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteStatusChanged),
name: .favoriteStatusChanged,
object: nil
)
// Listen for peer status updates to refresh UI
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePeerStatusUpdate),
name: Notification.Name("peerStatusUpdated"),
object: nil
)
#if os(macOS)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: NSApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: NSApplication.willTerminateNotification,
object: nil
)
#else
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillTerminate),
name: UIApplication.willTerminateNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(userDidTakeScreenshot),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
#endif
}
// MARK: - Deinitialization
@@ -3579,7 +3770,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
}()
let full = nostrKeyMapping[spid]?.lowercased() ?? bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
} else if spid.count == 16 {
} else if PeerIDResolver.isShortID(spid) {
// Mesh short ID
return getPeerPaletteColor(for: spid, isDark: isDark)
} else {
+2 -2
View File
@@ -43,14 +43,14 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status
let statusPeerID: String = {
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID
}()
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if peerID.count == 64, let data = Data(hexString: peerID) {
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {