diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift new file mode 100644 index 00000000..f0e0e44b --- /dev/null +++ b/bitchat/App/AppArchitecture.swift @@ -0,0 +1,363 @@ +import BitFoundation +import Combine +import Foundation + +enum SharedContentKind: String, Sendable, Equatable { + case text + case url +} + +enum RuntimeScenePhase: String, Sendable, Equatable { + case active + case inactive + case background +} + +enum TorLifecycleEvent: String, Sendable, Equatable { + case willStart + case willRestart + case didBecomeReady + case preferenceChanged +} + +enum AppEvent: Sendable, Equatable { + case launched + case startupCompleted + case scenePhaseChanged(RuntimeScenePhase) + case openedURL(String) + case sharedContentAccepted(SharedContentKind) + case notificationOpened(peerID: PeerID?) + case deepLinkOpened(String) + case torLifecycleChanged(TorLifecycleEvent) + case nostrRelayConnectionChanged(Bool) + case terminationRequested +} + +actor AppEventStream { + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + func stream() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + continuations[id] = continuation + continuation.onTermination = { [id] _ in + Task { + await self.removeContinuation(id) + } + } + } + } + + func emit(_ event: AppEvent) { + for continuation in continuations.values { + continuation.yield(event) + } + } + + func finish() { + for continuation in continuations.values { + continuation.finish() + } + continuations.removeAll() + } + + private func removeContinuation(_ id: UUID) { + continuations.removeValue(forKey: id) + } +} + +struct PeerHandle: Sendable, Identifiable { + let id: String + let routingPeerID: PeerID + let displayName: String? + let noisePublicKeyHex: String? + let nostrPublicKey: String? +} + +extension PeerHandle: Equatable { + static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool { + lhs.id == rhs.id + } +} + +extension PeerHandle: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum ConversationID: Hashable, Sendable { + case mesh + case geohash(String) + case direct(PeerHandle) + + init(channelID: ChannelID) { + switch channelID { + case .mesh: + self = .mesh + case .location(let channel): + self = .geohash(channel.geohash.lowercased()) + } + } +} + +@MainActor +final class IdentityResolver { + private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:] + private var handlesByNoiseKey: [String: PeerHandle] = [:] + private var handlesByNostrKey: [String: PeerHandle] = [:] + + func register(peers: [BitchatPeer]) { + for peer in peers { + _ = register(peer: peer) + } + } + + @discardableResult + func register(peer: BitchatPeer) -> PeerHandle { + let handle = buildHandle( + routingPeerID: peer.peerID, + displayName: peer.displayName, + noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(), + nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey) + ) + cache(handle) + return handle + } + + func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle { + if let handle = handlesByRoutingPeerID[peerID] { + return handle + } + + if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] { + return handle + } + + if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] { + return handle + } + + let handle = buildHandle( + routingPeerID: peerID, + displayName: displayName, + noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil, + nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil + ) + cache(handle) + return handle + } + + private func buildHandle( + routingPeerID: PeerID, + displayName: String?, + noisePublicKeyHex: String?, + nostrPublicKey: String? + ) -> PeerHandle { + let canonicalID: String + if let noisePublicKeyHex { + canonicalID = "noise:\(noisePublicKeyHex)" + } else if let nostrPublicKey { + canonicalID = "nostr:\(nostrPublicKey)" + } else { + canonicalID = "mesh:\(routingPeerID.id)" + } + + let normalizedDisplayName: String? + if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + normalizedDisplayName = displayName + } else { + normalizedDisplayName = nil + } + + return PeerHandle( + id: canonicalID, + routingPeerID: routingPeerID, + displayName: normalizedDisplayName, + noisePublicKeyHex: noisePublicKeyHex, + nostrPublicKey: nostrPublicKey + ) + } + + private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? { + guard let nostrPublicKey else { return nil } + let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + private func cache(_ handle: PeerHandle) { + handlesByRoutingPeerID[handle.routingPeerID] = handle + if let noisePublicKeyHex = handle.noisePublicKeyHex { + handlesByNoiseKey[noisePublicKeyHex] = handle + } + if let nostrPublicKey = handle.nostrPublicKey { + handlesByNostrKey[nostrPublicKey] = handle + } + } +} + +@MainActor +final class ConversationStore: ObservableObject { + @Published private(set) var activeChannel: ChannelID = .mesh + @Published private(set) var selectedPrivatePeerID: PeerID? + @Published private(set) var selectedConversationID: ConversationID = .mesh + @Published private(set) var unreadConversations: Set = [] + @Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:] + + private var directHandlesByConversation: [ConversationID: PeerHandle] = [:] + + func setActiveChannel(_ channelID: ChannelID) { + activeChannel = channelID + if selectedPrivatePeerID == nil { + selectedConversationID = ConversationID(channelID: channelID) + } + } + + func setSelectedPeerID( + _ peerID: PeerID?, + activeChannel: ChannelID, + identityResolver: IdentityResolver + ) { + self.activeChannel = activeChannel + selectedPrivatePeerID = peerID + + if let peerID { + selectedConversationID = directConversationID( + for: peerID, + identityResolver: identityResolver + ) + } else { + selectedConversationID = ConversationID(channelID: activeChannel) + } + } + + func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { + messagesByConversation[conversationID] = normalized(messages) + } + + func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { + replaceMessages(messages, for: ConversationID(channelID: channelID)) + } + + func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) { + setActiveChannel(activeChannel) + replaceMessages(messages, for: activeChannel) + } + + func messages(for conversationID: ConversationID) -> [BitchatMessage] { + messagesByConversation[conversationID] ?? [] + } + + func directMessages( + for peerID: PeerID, + identityResolver: IdentityResolver + ) -> [BitchatMessage] { + messages(for: directConversationID(for: peerID, identityResolver: identityResolver)) + } + + func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] { + var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] + + for (conversationID, handle) in directHandlesByConversation { + messagesByPeerID[handle.routingPeerID] = messages(for: conversationID) + } + + return messagesByPeerID + } + + func unreadDirectPeerIDs() -> Set { + unreadConversations.reduce(into: Set()) { result, conversationID in + guard case .direct(let handle) = conversationID else { return } + result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID) + } + } + + func synchronizeSelection( + activeChannel: ChannelID, + selectedPeerID: PeerID?, + identityResolver: IdentityResolver + ) { + setSelectedPeerID( + selectedPeerID, + activeChannel: activeChannel, + identityResolver: identityResolver + ) + } + + func synchronizePrivateChats( + _ privateChats: [PeerID: [BitchatMessage]], + unreadPeerIDs: Set, + identityResolver: IdentityResolver + ) { + var liveConversations = Set() + + for (peerID, messages) in privateChats { + let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender) + let conversationID = ConversationID.direct(handle) + liveConversations.insert(conversationID) + directHandlesByConversation[conversationID] = handle + messagesByConversation[conversationID] = normalized(messages) + } + + let staleDirectConversations = messagesByConversation.keys.filter { conversationID in + guard case .direct = conversationID else { return false } + return !liveConversations.contains(conversationID) + } + + for conversationID in staleDirectConversations { + messagesByConversation.removeValue(forKey: conversationID) + unreadConversations.remove(conversationID) + directHandlesByConversation.removeValue(forKey: conversationID) + } + + let publicUnread = unreadConversations.filter { conversationID in + switch conversationID { + case .mesh, .geohash: + return true + case .direct: + return false + } + } + + unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in + let handle = identityResolver.canonicalHandle(for: peerID) + result.insert(.direct(handle)) + } + } + + func markRead(_ conversationID: ConversationID) { + unreadConversations.remove(conversationID) + } + + func markRead( + peerID: PeerID, + identityResolver: IdentityResolver + ) { + markRead(directConversationID(for: peerID, identityResolver: identityResolver)) + } + + private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] { + var uniqueMessages: [String: BitchatMessage] = [:] + + for message in messages { + uniqueMessages[message.id] = message + } + + return uniqueMessages.values.sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { + return lhs.timestamp < rhs.timestamp + } + return lhs.id < rhs.id + } + } + + private func directConversationID( + for peerID: PeerID, + identityResolver: IdentityResolver + ) -> ConversationID { + let handle = identityResolver.canonicalHandle(for: peerID) + let conversationID = ConversationID.direct(handle) + directHandlesByConversation[conversationID] = handle + return conversationID + } +} diff --git a/bitchat/App/AppChromeModel.swift b/bitchat/App/AppChromeModel.swift new file mode 100644 index 00000000..3f9cacd7 --- /dev/null +++ b/bitchat/App/AppChromeModel.swift @@ -0,0 +1,100 @@ +import BitFoundation +import Combine +import CoreBluetooth +import Foundation + +@MainActor +final class AppChromeModel: ObservableObject { + @Published private(set) var hasUnreadPrivateMessages = false + @Published var nickname: String + @Published var showingFingerprintFor: PeerID? + @Published var isAppInfoPresented = false + @Published var isLocationChannelsSheetPresented = false + @Published var showBluetoothAlert = false + @Published var bluetoothAlertMessage = "" + @Published var bluetoothState: CBManagerState = .unknown + @Published var showScreenshotPrivacyWarning = false + + private let chatViewModel: ChatViewModel + private var cancellables = Set() + + init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) { + self.chatViewModel = chatViewModel + self.nickname = chatViewModel.nickname + + bind(privateInboxModel: privateInboxModel) + } + + var shouldSuppressScreenshotNotification: Bool { + isLocationChannelsSheetPresented || isAppInfoPresented + } + + func setNickname(_ nickname: String) { + self.nickname = nickname + if chatViewModel.nickname != nickname { + chatViewModel.nickname = nickname + } + } + + func validateAndSaveNickname() { + chatViewModel.validateAndSaveNickname() + if nickname != chatViewModel.nickname { + nickname = chatViewModel.nickname + } + } + + func openMostRelevantPrivateChat() { + chatViewModel.openMostRelevantPrivateChat() + } + + func showFingerprint(for peerID: PeerID) { + showingFingerprintFor = peerID + } + + func clearFingerprint() { + showingFingerprintFor = nil + } + + func presentAppInfo() { + isAppInfoPresented = true + } + + func triggerScreenshotPrivacyWarning() { + showScreenshotPrivacyWarning = true + } + + func panicClearAllData() { + chatViewModel.panicClearAllData() + } + + private func bind(privateInboxModel: PrivateInboxModel) { + privateInboxModel.$unreadPeerIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] unreadPeerIDs in + self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty + } + .store(in: &cancellables) + + chatViewModel.$nickname + .receive(on: DispatchQueue.main) + .sink { [weak self] nickname in + guard let self, self.nickname != nickname else { return } + self.nickname = nickname + } + .store(in: &cancellables) + + chatViewModel.$showBluetoothAlert + .receive(on: DispatchQueue.main) + .assign(to: &$showBluetoothAlert) + + chatViewModel.$bluetoothAlertMessage + .receive(on: DispatchQueue.main) + .assign(to: &$bluetoothAlertMessage) + + chatViewModel.$bluetoothState + .receive(on: DispatchQueue.main) + .assign(to: &$bluetoothState) + + hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty + } +} diff --git a/bitchat/App/AppRuntime.swift b/bitchat/App/AppRuntime.swift new file mode 100644 index 00000000..f4aba8c8 --- /dev/null +++ b/bitchat/App/AppRuntime.swift @@ -0,0 +1,387 @@ +import BitFoundation +import Combine +import Foundation +import SwiftUI +import Tor +import UserNotifications +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +final class AppRuntime: ObservableObject { + let chatViewModel: ChatViewModel + let events = AppEventStream() + let conversationStore: ConversationStore + let peerIdentityStore: PeerIdentityStore + let locationPresenceStore: LocationPresenceStore + let publicChatModel: PublicChatModel + let privateInboxModel: PrivateInboxModel + let privateConversationModel: PrivateConversationModel + let verificationModel: VerificationModel + let conversationUIModel: ConversationUIModel + let locationChannelsModel: LocationChannelsModel + let peerListModel: PeerListModel + let appChromeModel: AppChromeModel + + private let idBridge: NostrIdentityBridge + private var cancellables = Set() + private var started = false + private var lastNostrRelayConnectedState = false + private var didHandleInitialNostrConnection = false + + #if os(iOS) + private var didHandleInitialActive = false + private var didEnterBackground = false + #endif + + init( + keychain: KeychainManagerProtocol = KeychainManager(), + idBridge: NostrIdentityBridge = NostrIdentityBridge() + ) { + self.idBridge = idBridge + let identityResolver = IdentityResolver() + let conversationStore = ConversationStore() + let peerIdentityStore = PeerIdentityStore() + let locationPresenceStore = LocationPresenceStore() + let locationManager = LocationChannelManager.shared + self.conversationStore = conversationStore + self.peerIdentityStore = peerIdentityStore + self.locationPresenceStore = locationPresenceStore + self.chatViewModel = ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: SecureIdentityStateManager(keychain), + conversationStore: conversationStore, + identityResolver: identityResolver, + peerIdentityStore: peerIdentityStore, + locationPresenceStore: locationPresenceStore, + locationManager: locationManager + ) + self.publicChatModel = PublicChatModel(conversationStore: conversationStore) + self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + self.locationChannelsModel = LocationChannelsModel(manager: locationManager) + self.privateConversationModel = PrivateConversationModel( + chatViewModel: self.chatViewModel, + conversationStore: conversationStore, + locationChannelsModel: self.locationChannelsModel, + peerIdentityStore: peerIdentityStore + ) + self.verificationModel = VerificationModel( + chatViewModel: self.chatViewModel, + privateConversationModel: self.privateConversationModel, + peerIdentityStore: peerIdentityStore + ) + self.conversationUIModel = ConversationUIModel( + chatViewModel: self.chatViewModel, + privateConversationModel: self.privateConversationModel, + conversationStore: conversationStore + ) + self.peerListModel = PeerListModel( + chatViewModel: self.chatViewModel, + conversationStore: conversationStore, + locationChannelsModel: self.locationChannelsModel, + peerIdentityStore: peerIdentityStore, + locationPresenceStore: locationPresenceStore + ) + self.appChromeModel = AppChromeModel( + chatViewModel: self.chatViewModel, + privateInboxModel: self.privateInboxModel + ) + + GeoRelayDirectory.shared.prefetchIfNeeded() + bindRuntimeObservers() + NotificationDelegate.shared.runtime = self + } + + func start() { + guard !started else { + checkForSharedContent() + return + } + + started = true + NotificationDelegate.shared.runtime = self + VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) + announceInitialTorStatusIfNeeded() + + Task(priority: .utility) { [weak self] in + guard let self else { return } + let nickname = await MainActor.run { self.chatViewModel.nickname } + let npub = await MainActor.run { + try? self.idBridge.getCurrentNostrIdentity()?.npub + } + await MainActor.run { + _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) + } + } + + NetworkActivationService.shared.start() + GeohashPresenceService.shared.start() + checkForSharedContent() + + record(.launched) + record(.startupCompleted) + } + + func handleOpenURL(_ url: URL) { + record(.openedURL(url.absoluteString)) + + if url.scheme == "bitchat", url.host == "share" { + checkForSharedContent() + } + } + + func handleDidBecomeActiveNotification() { + chatViewModel.handleDidBecomeActive() + checkForSharedContent() + } + + #if os(macOS) + func handleMacDidBecomeActiveNotification() { + record(.scenePhaseChanged(.active)) + chatViewModel.handleDidBecomeActive() + checkForSharedContent() + } + #endif + + #if os(iOS) + func handleScenePhaseChange(_ newPhase: ScenePhase) { + switch newPhase { + case .background: + record(.scenePhaseChanged(.background)) + TorManager.shared.setAppForeground(false) + TorManager.shared.goDormantOnBackground() + chatViewModel.endGeohashSampling() + NostrRelayManager.shared.disconnect() + didEnterBackground = true + + case .active: + record(.scenePhaseChanged(.active)) + chatViewModel.meshService.startServices() + TorManager.shared.setAppForeground(true) + let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground + + if didHandleInitialActive && didEnterBackground { + if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { + TorManager.shared.ensureRunningOnForeground() + } + } else { + didHandleInitialActive = true + } + + didEnterBackground = false + + if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() { + Task.detached { + let _ = await TorManager.shared.awaitReady(timeout: 60) + await MainActor.run { + TorURLSession.shared.rebuild() + NostrRelayManager.shared.resetAllConnections() + } + } + } + + chatViewModel.handleDidBecomeActive() + checkForSharedContent() + + case .inactive: + record(.scenePhaseChanged(.inactive)) + + @unknown default: + break + } + } + #endif + + func applicationWillTerminate() { + record(.terminationRequested) + chatViewModel.applicationWillTerminate() + } + + func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) { + if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { + record(.notificationOpened(peerID: peerID)) + chatViewModel.startPrivateChat(with: peerID) + } + + if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) { + record(.deepLinkOpened(deepLink)) + openExternalURL(url) + } + } + + func presentationOptions( + forNotificationIdentifier identifier: String, + userInfo: [AnyHashable: Any] + ) async -> UNNotificationPresentationOptions { + if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { + if conversationStore.selectedPrivatePeerID == peerID { + return [] + } + return [.banner, .sound] + } + + if identifier.hasPrefix("geo-activity-"), + let deepLink = userInfo["deeplink"] as? String, + let geohash = deepLink.components(separatedBy: "/").last, + case .location(let channel) = locationChannelsModel.selectedChannel, + channel.geohash == geohash { + return [] + } + + return [.banner, .sound] + } +} + +private extension AppRuntime { + func bindRuntimeObservers() { + NostrRelayManager.shared.$isConnected + .receive(on: DispatchQueue.main) + .sink { [weak self] isConnected in + self?.handleNostrRelayConnectionChanged(isConnected) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TorWillRestart) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.record(.torLifecycleChanged(.willRestart)) + self?.chatViewModel.handleTorWillRestart() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TorDidBecomeReady) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.record(.torLifecycleChanged(.didBecomeReady)) + self?.chatViewModel.handleTorDidBecomeReady() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TorWillStart) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.record(.torLifecycleChanged(.willStart)) + self?.chatViewModel.handleTorWillStart() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + self?.record(.torLifecycleChanged(.preferenceChanged)) + self?.chatViewModel.handleTorPreferenceChanged(notification) + } + .store(in: &cancellables) + + #if os(iOS) + NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleScreenshotCaptured() + } + .store(in: &cancellables) + #endif + } + + func checkForSharedContent() { + guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID), + let sharedContent = userDefaults.string(forKey: "sharedContent"), + let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { + return + } + + guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else { + return + } + + let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text + + userDefaults.removeObject(forKey: "sharedContent") + userDefaults.removeObject(forKey: "sharedContentType") + userDefaults.removeObject(forKey: "sharedContentDate") + + switch contentKind { + case .url: + if let data = sharedContent.data(using: .utf8), + let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], + let url = urlData["url"] { + chatViewModel.sendMessage(url) + } else { + chatViewModel.sendMessage(sharedContent) + } + case .text: + chatViewModel.sendMessage(sharedContent) + } + + record(.sharedContentAccepted(contentKind)) + } + + func handleNostrRelayConnectionChanged(_ isConnected: Bool) { + record(.nostrRelayConnectionChanged(isConnected)) + + let becameConnected = isConnected && !lastNostrRelayConnectedState + lastNostrRelayConnectedState = isConnected + + guard started, becameConnected else { return } + + let isInitialConnection = !didHandleInitialNostrConnection + didHandleInitialNostrConnection = true + + if !chatViewModel.nostrHandlersSetup { + chatViewModel.setupNostrMessageHandling() + chatViewModel.nostrHandlersSetup = true + } + + guard !isInitialConnection else { return } + + chatViewModel.resubscribeCurrentGeohash() + chatViewModel.geoChannelCoordinator?.refreshSampling() + } + + func announceInitialTorStatusIfNeeded() { + if TorManager.shared.torEnforced && + !chatViewModel.torStatusAnnounced && + TorManager.shared.isAutoStartAllowed() { + chatViewModel.torStatusAnnounced = true + chatViewModel.addGeohashOnlySystemMessage( + String(localized: "system.tor.starting", comment: "System message when Tor is starting") + ) + } else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced { + chatViewModel.torStatusAnnounced = true + chatViewModel.addGeohashOnlySystemMessage( + String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") + ) + } + } + + func handleScreenshotCaptured() { + if appChromeModel.isLocationChannelsSheetPresented { + appChromeModel.triggerScreenshotPrivacyWarning() + return + } + + if appChromeModel.isAppInfoPresented { + return + } + + chatViewModel.handleScreenshotCaptured() + } + + func openExternalURL(_ url: URL) { + #if os(iOS) + UIApplication.shared.open(url) + #else + NSWorkspace.shared.open(url) + #endif + } + + func record(_ event: AppEvent) { + Task { + await events.emit(event) + } + } +} diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift new file mode 100644 index 00000000..01888d35 --- /dev/null +++ b/bitchat/App/ConversationUIModel.swift @@ -0,0 +1,183 @@ +import BitFoundation +import Combine +import SwiftUI +#if os(iOS) +import UIKit +#endif + +@MainActor +final class ConversationUIModel: ObservableObject { + @Published private(set) var showAutocomplete = false + @Published private(set) var autocompleteSuggestions: [String] = [] + @Published private(set) var currentNickname: String + @Published private(set) var isBatchingPublic = false + @Published private(set) var canSendMediaInCurrentContext = true + + private let chatViewModel: ChatViewModel + private let privateConversationModel: PrivateConversationModel + private let conversationStore: ConversationStore + private var activeChannel: ChannelID + private var cancellables = Set() + + init( + chatViewModel: ChatViewModel, + privateConversationModel: PrivateConversationModel, + conversationStore: ConversationStore + ) { + self.chatViewModel = chatViewModel + self.privateConversationModel = privateConversationModel + self.conversationStore = conversationStore + self.activeChannel = conversationStore.activeChannel + self.currentNickname = chatViewModel.nickname + self.isBatchingPublic = chatViewModel.isBatchingPublic + self.showAutocomplete = chatViewModel.showAutocomplete + self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions + self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext + + bind() + } + + func setCurrentColorScheme(_ colorScheme: ColorScheme) { + chatViewModel.currentColorScheme = colorScheme + } + + func sendMessage(_ message: String) { + chatViewModel.sendMessage(message) + } + + func clearCurrentConversation() { + chatViewModel.sendMessage("/clear") + } + + func sendHug(to sender: String) { + chatViewModel.sendMessage("/hug @\(sender)") + } + + func sendSlap(to sender: String) { + chatViewModel.sendMessage("/slap @\(sender)") + } + + func block(peerID: PeerID?, displayName: String?) { + guard let displayName else { return } + + if let peerID, peerID.isGeoChat, + let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { + chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) + } else { + chatViewModel.sendMessage("/block \(displayName)") + } + } + + func updateAutocomplete(for text: String, cursorPosition: Int) { + chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) + } + + func completeNickname(_ nickname: String, in text: inout String) -> Int { + chatViewModel.completeNickname(nickname, in: &text) + } + + func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + chatViewModel.formatMessageAsText(message, colorScheme: colorScheme) + } + + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + chatViewModel.formatMessageHeader(message, colorScheme: colorScheme) + } + + func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? { + message.mediaAttachment(for: currentNickname) + } + + func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { + chatViewModel.isSelfSender(peerID: peerID, displayName: displayName) + } + + func isSentByCurrentUser(_ message: BitchatMessage) -> Bool { + message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#") + } + + func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool { + message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID + } + + func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? { + if peerID.isGeoDM || peerID.isGeoChat { + return chatViewModel.geohashDisplayName(for: peerID) + } + if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) { + return nickname + } + return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender + } + + #if os(iOS) + func processSelectedImage(_ image: UIImage?) { + chatViewModel.processThenSendImage(image) + } + #endif + + func processSelectedImage(from url: URL?) { + #if os(macOS) + chatViewModel.processThenSendImage(from: url) + #endif + } + + func sendVoiceNote(at url: URL) { + chatViewModel.sendVoiceNote(at: url) + } + + func cancelMediaSend(messageID: String) { + chatViewModel.cancelMediaSend(messageID: messageID) + } + + func deleteMediaMessage(messageID: String) { + chatViewModel.deleteMediaMessage(messageID: messageID) + } + + private func bind() { + chatViewModel.$nickname + .receive(on: DispatchQueue.main) + .assign(to: &$currentNickname) + + chatViewModel.$showAutocomplete + .receive(on: DispatchQueue.main) + .assign(to: &$showAutocomplete) + + chatViewModel.$autocompleteSuggestions + .receive(on: DispatchQueue.main) + .assign(to: &$autocompleteSuggestions) + + chatViewModel.$isBatchingPublic + .receive(on: DispatchQueue.main) + .assign(to: &$isBatchingPublic) + + conversationStore.$activeChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] channel in + self?.activeChannel = channel + self?.refreshComputedState() + } + .store(in: &cancellables) + + privateConversationModel.$selectedPeerID + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshComputedState() + } + .store(in: &cancellables) + } + + private func refreshComputedState() { + if let selectedPeerID = privateConversationModel.selectedPeerID { + canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat) + return + } + + switch activeChannel { + case .mesh: + canSendMediaInCurrentContext = true + case .location: + canSendMediaInCurrentContext = false + } + } +} diff --git a/bitchat/App/LocationChannelsModel.swift b/bitchat/App/LocationChannelsModel.swift new file mode 100644 index 00000000..c820455a --- /dev/null +++ b/bitchat/App/LocationChannelsModel.swift @@ -0,0 +1,176 @@ +import BitFoundation +import Combine +import Foundation + +@MainActor +final class LocationChannelsModel: ObservableObject { + @Published private(set) var permissionState: LocationChannelManager.PermissionState + @Published private(set) var availableChannels: [GeohashChannel] + @Published private(set) var selectedChannel: ChannelID + @Published private(set) var teleported: Bool + @Published private(set) var bookmarks: [String] + @Published private(set) var bookmarkNames: [String: String] + @Published private(set) var locationNames: [GeohashChannelLevel: String] + @Published private(set) var userTorEnabled: Bool + + private let manager: LocationChannelManager + private let network: NetworkActivationService + private var cancellables = Set() + + init( + manager: LocationChannelManager? = nil, + network: NetworkActivationService? = nil + ) { + let manager = manager ?? .shared + let network = network ?? .shared + + self.manager = manager + self.network = network + self.permissionState = manager.permissionState + self.availableChannels = manager.availableChannels + self.selectedChannel = manager.selectedChannel + self.teleported = manager.teleported + self.bookmarks = manager.bookmarks + self.bookmarkNames = manager.bookmarkNames + self.locationNames = manager.locationNames + self.userTorEnabled = network.userTorEnabled + + bind() + } + + var currentBuildingGeohash: String? { + availableChannels.first(where: { $0.level == .building })?.geohash + } + + func isSelected(_ channel: GeohashChannel) -> Bool { + guard case .location(let selected) = selectedChannel else { return false } + return selected == channel + } + + func isBookmarked(_ geohash: String) -> Bool { + manager.isBookmarked(geohash) + } + + func enableLocationChannels() { + manager.enableLocationChannels() + } + + func refreshChannels() { + manager.refreshChannels() + } + + func enableAndRefresh() { + manager.enableLocationChannels() + manager.refreshChannels() + } + + func beginLiveRefresh() { + manager.beginLiveRefresh() + } + + func endLiveRefresh() { + manager.endLiveRefresh() + } + + func select(_ channel: ChannelID) { + manager.select(channel) + } + + func markTeleported(for geohash: String, _ flag: Bool) { + manager.markTeleported(for: geohash, flag) + } + + func toggleBookmark(_ geohash: String) { + manager.toggleBookmark(geohash) + } + + func resolveBookmarkNameIfNeeded(for geohash: String) { + manager.resolveBookmarkNameIfNeeded(for: geohash) + } + + func locationName(for level: GeohashChannelLevel) -> String? { + locationNames[level] + } + + func setUserTorEnabled(_ enabled: Bool) { + network.setUserTorEnabled(enabled) + } + + func refreshMeshChannelsIfNeeded() { + guard case .mesh = selectedChannel, + permissionState == .authorized, + availableChannels.isEmpty else { + return + } + refreshChannels() + } + + func openLocationChannel(for geohash: String) { + let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + guard (2...12).contains(normalized.count), + normalized.allSatisfy({ allowed.contains($0) }) else { + return + } + + let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized) + let isRegional = availableChannels.contains { $0.geohash == normalized } + if !isRegional && !availableChannels.isEmpty { + markTeleported(for: normalized, true) + } + select(.location(channel)) + } + + func teleport(to geohash: String) { + let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized) + markTeleported(for: normalized, true) + select(.location(channel)) + } + + private func bind() { + manager.$permissionState + .receive(on: DispatchQueue.main) + .assign(to: &$permissionState) + + manager.$availableChannels + .receive(on: DispatchQueue.main) + .assign(to: &$availableChannels) + + manager.$selectedChannel + .receive(on: DispatchQueue.main) + .assign(to: &$selectedChannel) + + manager.$teleported + .receive(on: DispatchQueue.main) + .assign(to: &$teleported) + + manager.$bookmarks + .receive(on: DispatchQueue.main) + .assign(to: &$bookmarks) + + manager.$bookmarkNames + .receive(on: DispatchQueue.main) + .assign(to: &$bookmarkNames) + + manager.$locationNames + .receive(on: DispatchQueue.main) + .assign(to: &$locationNames) + + network.$userTorEnabled + .receive(on: DispatchQueue.main) + .assign(to: &$userTorEnabled) + } + + private func level(forLength length: Int) -> GeohashChannelLevel { + switch length { + case 0...2: return .region + case 3...4: return .province + case 5: return .city + case 6: return .neighborhood + case 7: return .block + case 8...12: return .building + default: return .block + } + } +} diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift new file mode 100644 index 00000000..499eeb86 --- /dev/null +++ b/bitchat/App/LocationPresenceStore.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation + +@MainActor +final class LocationPresenceStore: ObservableObject { + @Published private(set) var currentGeohash: String? + @Published private(set) var geoNicknames: [String: String] = [:] + @Published private(set) var teleportedGeo: Set = [] + + func setCurrentGeohash(_ geohash: String?) { + currentGeohash = geohash?.lowercased() + } + + func setNickname(_ nickname: String, for pubkeyHex: String) { + geoNicknames[pubkeyHex.lowercased()] = nickname + } + + func replaceGeoNicknames(_ nicknames: [String: String]) { + geoNicknames = Dictionary( + uniqueKeysWithValues: nicknames.map { key, value in + (key.lowercased(), value) + } + ) + } + + func clearGeoNicknames() { + geoNicknames.removeAll() + } + + func markTeleported(_ pubkeyHex: String) { + teleportedGeo.insert(pubkeyHex.lowercased()) + } + + func clearTeleported(_ pubkeyHex: String) { + teleportedGeo.remove(pubkeyHex.lowercased()) + } + + func replaceTeleportedGeo(_ pubkeys: Set) { + teleportedGeo = Set(pubkeys.map { $0.lowercased() }) + } + + func clearTeleportedGeo() { + teleportedGeo.removeAll() + } + + func reset() { + currentGeohash = nil + geoNicknames.removeAll() + teleportedGeo.removeAll() + } +} diff --git a/bitchat/App/PeerIdentityStore.swift b/bitchat/App/PeerIdentityStore.swift new file mode 100644 index 00000000..119d765f --- /dev/null +++ b/bitchat/App/PeerIdentityStore.swift @@ -0,0 +1,125 @@ +import BitFoundation +import Combine +import Foundation + +@MainActor +final class PeerIdentityStore: ObservableObject { + @Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:] + @Published private(set) var verifiedFingerprints: Set = [] + + private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:] + private(set) var selectedPrivateChatFingerprint: String? + + private var stablePeerIDsByShortID: [PeerID: PeerID] = [:] + private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:] + + func stablePeerID(forShortID peerID: PeerID) -> PeerID? { + stablePeerIDsByShortID[peerID] + } + + func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? { + stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key + } + + func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) { + stablePeerIDsByShortID[peerID] = stablePeerID + } + + func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) { + stablePeerIDsByShortID = mappings + } + + func fingerprint(for peerID: PeerID) -> String? { + peerFingerprintsByPeerID[peerID] + } + + func setFingerprint(_ fingerprint: String?, for peerID: PeerID) { + if let fingerprint { + peerFingerprintsByPeerID[peerID] = fingerprint + } else { + peerFingerprintsByPeerID.removeValue(forKey: peerID) + } + } + + func replaceFingerprintMappings(_ mappings: [PeerID: String]) { + peerFingerprintsByPeerID = mappings + } + + @discardableResult + func migrateFingerprintMapping( + from oldPeerID: PeerID, + to newPeerID: PeerID, + fallback: String? = nil + ) -> String? { + let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback + if let fingerprint { + peerFingerprintsByPeerID[newPeerID] = fingerprint + if selectedPrivateChatFingerprint == nil { + selectedPrivateChatFingerprint = fingerprint + } + } + return fingerprint + } + + func setSelectedPrivateChatFingerprint(_ fingerprint: String?) { + selectedPrivateChatFingerprint = fingerprint + } + + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + encryptionStatusCache[peerID] + } + + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { + encryptionStatusCache[peerID] = status + } + + func invalidateEncryptionCache(for peerID: PeerID? = nil) { + if let peerID { + encryptionStatusCache.removeValue(forKey: peerID) + } else { + encryptionStatusCache.removeAll() + } + } + + func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + encryptionStatuses[peerID] + } + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + if let status { + encryptionStatuses[peerID] = status + } else { + encryptionStatuses.removeValue(forKey: peerID) + } + invalidateEncryptionCache(for: peerID) + } + + func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) { + encryptionStatuses = statuses + } + + func setVerifiedFingerprints(_ fingerprints: Set) { + verifiedFingerprints = fingerprints + } + + func setVerified(_ fingerprint: String, verified: Bool) { + if verified { + verifiedFingerprints.insert(fingerprint) + } else { + verifiedFingerprints.remove(fingerprint) + } + } + + func isVerified(_ fingerprint: String) -> Bool { + verifiedFingerprints.contains(fingerprint) + } + + func clearAll() { + encryptionStatuses.removeAll() + verifiedFingerprints.removeAll() + peerFingerprintsByPeerID.removeAll() + selectedPrivateChatFingerprint = nil + stablePeerIDsByShortID.removeAll() + encryptionStatusCache.removeAll() + } +} diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift new file mode 100644 index 00000000..91993d47 --- /dev/null +++ b/bitchat/App/PeerListModel.swift @@ -0,0 +1,260 @@ +import BitFoundation +import Combine +import SwiftUI + +struct MeshPeerRow: Identifiable, Equatable { + let peerID: PeerID + let displayName: String + let isMe: Bool + let hasUnread: Bool + let isBlocked: Bool + let isFavorite: Bool + let isConnected: Bool + let isReachable: Bool + let isMutualFavorite: Bool + let encryptionStatus: EncryptionStatus + let showsVerifiedBadgeWhenOffline: Bool + + var id: String { peerID.id } +} + +struct GeohashPersonRow: Identifiable, Equatable { + let id: String + let displayName: String + let isMe: Bool + let isTeleported: Bool + let isBlocked: Bool +} + +@MainActor +final class PeerListModel: ObservableObject { + @Published private(set) var allPeers: [BitchatPeer] = [] + @Published private(set) var meshRows: [MeshPeerRow] = [] + @Published private(set) var geohashPeople: [GeohashPersonRow] = [] + @Published private(set) var reachableMeshPeerCount = 0 + @Published private(set) var connectedMeshPeerCount = 0 + @Published private(set) var visibleGeohashPeerCount = 0 + @Published private(set) var renderID = "" + + private let chatViewModel: ChatViewModel + private let conversationStore: ConversationStore + private let locationChannelsModel: LocationChannelsModel + private let peerIdentityStore: PeerIdentityStore + private let locationPresenceStore: LocationPresenceStore + private var cancellables = Set() + + init( + chatViewModel: ChatViewModel, + conversationStore: ConversationStore, + locationChannelsModel: LocationChannelsModel? = nil, + peerIdentityStore: PeerIdentityStore? = nil, + locationPresenceStore: LocationPresenceStore? = nil + ) { + self.chatViewModel = chatViewModel + self.conversationStore = conversationStore + self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() + self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore + self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore + self.allPeers = chatViewModel.allPeers + + bind() + refresh() + } + + func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { + chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark) + } + + func colorForGeohashPerson(id: String, isDark: Bool) -> Color { + chatViewModel.colorForNostrPubkey(id, isDark: isDark) + } + + func participantCount(for geohash: String) -> Int { + chatViewModel.geohashParticipantCount(for: geohash) + } + + func startConversation(with peerID: PeerID) { + chatViewModel.startPrivateChat(with: peerID) + } + + func toggleFavorite(peerID: PeerID) { + chatViewModel.toggleFavorite(peerID: peerID) + } + + func openGeohashDirectMessage(with pubkeyHex: String) { + chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex) + } + + func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + chatViewModel.blockGeohashUser( + pubkeyHexLowercased: pubkeyHexLowercased, + displayName: displayName + ) + } + + func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + chatViewModel.unblockGeohashUser( + pubkeyHexLowercased: pubkeyHexLowercased, + displayName: displayName + ) + } + + private func bind() { + chatViewModel.$allPeers + .receive(on: DispatchQueue.main) + .sink { [weak self] peers in + self?.allPeers = peers + self?.refresh() + } + .store(in: &cancellables) + + chatViewModel.$nickname + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + locationPresenceStore.$teleportedGeo + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + conversationStore.$unreadConversations + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + peerIdentityStore.$encryptionStatuses + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + peerIdentityStore.$verifiedFingerprints + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + chatViewModel.participantTracker.$visiblePeople + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + locationChannelsModel.$selectedChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + locationChannelsModel.$teleported + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + + locationChannelsModel.$availableChannels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refresh() + } + .store(in: &cancellables) + } + + private func refresh() { + let myPeerID = chatViewModel.meshService.myPeerID + let meshRows = allPeers.map { peer in + let isMe = peer.peerID == myPeerID + let verifiedBadge: Bool + if !isMe && !peer.isConnected, + let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) { + verifiedBadge = peerIdentityStore.isVerified(fingerprint) + } else { + verifiedBadge = false + } + + return MeshPeerRow( + peerID: peer.peerID, + displayName: isMe ? chatViewModel.nickname : peer.nickname, + isMe: isMe, + hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID), + isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID), + isFavorite: peer.favoriteStatus?.isFavorite ?? false, + isConnected: peer.isConnected, + isReachable: peer.isReachable, + isMutualFavorite: peer.isMutualFavorite, + encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), + showsVerifiedBadgeWhenOffline: verifiedBadge + ) + } + + let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in + guard !row.isMe else { return } + if row.isConnected { + counts.connected += 1 + counts.reachable += 1 + } else if row.isReachable { + counts.reachable += 1 + } + } + + let geohashPeople = buildGeohashPeople() + + self.meshRows = meshRows + reachableMeshPeerCount = meshCounts.reachable + connectedMeshPeerCount = meshCounts.connected + self.geohashPeople = geohashPeople + visibleGeohashPeerCount = geohashPeople.count + renderID = ( + meshRows.map { + "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" + } + + geohashPeople.map { + "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" + } + ).joined(separator: "|") + } + + private func buildGeohashPeople() -> [GeohashPersonRow] { + let myHex = currentGeohashIdentityHex() + let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) + + return chatViewModel.visibleGeohashPeople().map { person in + let isMe = person.id == myHex + return GeohashPersonRow( + id: person.id, + displayName: person.displayName, + isMe: isMe, + isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported), + isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) + ) + } + } + + private func currentGeohashIdentityHex() -> String? { + guard case .location(let channel) = locationChannelsModel.selectedChannel, + let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { + return nil + } + + return identity.publicKeyHex.lowercased() + } +} diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift new file mode 100644 index 00000000..6403e105 --- /dev/null +++ b/bitchat/App/PrivateConversationModels.swift @@ -0,0 +1,298 @@ +import BitFoundation +import Combine +import Foundation + +@MainActor +final class PrivateInboxModel: ObservableObject { + @Published private(set) var selectedPeerID: PeerID? + @Published private(set) var unreadPeerIDs: Set = [] + @Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:] + + private let conversationStore: ConversationStore + private var cancellables = Set() + + init(conversationStore: ConversationStore) { + self.conversationStore = conversationStore + + bind() + refreshMessages() + } + + func messages(for peerID: PeerID?) -> [BitchatMessage] { + guard let peerID else { return [] } + return messagesByPeerID[peerID] ?? [] + } + + private func bind() { + conversationStore.$selectedPrivatePeerID + .receive(on: DispatchQueue.main) + .sink { [weak self] peerID in + self?.selectedPeerID = peerID + self?.refreshMessages() + } + .store(in: &cancellables) + + conversationStore.$unreadConversations + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? [] + self?.refreshMessages() + } + .store(in: &cancellables) + + conversationStore.$messagesByConversation + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshMessages() + } + .store(in: &cancellables) + + selectedPeerID = conversationStore.selectedPrivatePeerID + unreadPeerIDs = conversationStore.unreadDirectPeerIDs() + } + + private func refreshMessages() { + var nextMessagesByPeerID = conversationStore.directMessagesByPeerID() + var peerIDs = Set(nextMessagesByPeerID.keys) + peerIDs.formUnion(conversationStore.unreadDirectPeerIDs()) + if let selectedPeerID = conversationStore.selectedPrivatePeerID { + peerIDs.insert(selectedPeerID) + } + + for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil { + nextMessagesByPeerID[peerID] = [] + } + + messagesByPeerID = nextMessagesByPeerID + } +} + +enum PrivateConversationAvailability: Equatable { + case bluetoothConnected + case meshReachable + case nostrAvailable + case offline +} + +struct PrivateConversationHeaderState: Equatable { + let conversationPeerID: PeerID + let headerPeerID: PeerID + let displayName: String + let availability: PrivateConversationAvailability + let isFavorite: Bool + let encryptionStatus: EncryptionStatus? + + var supportsFavoriteToggle: Bool { + !conversationPeerID.isGeoDM + } +} + +@MainActor +final class PrivateConversationModel: ObservableObject { + @Published private(set) var selectedPeerID: PeerID? + @Published private(set) var selectedHeaderState: PrivateConversationHeaderState? + + private let chatViewModel: ChatViewModel + private let conversationStore: ConversationStore + private let locationChannelsModel: LocationChannelsModel + private let peerIdentityStore: PeerIdentityStore + private var cancellables = Set() + + init( + chatViewModel: ChatViewModel, + conversationStore: ConversationStore, + locationChannelsModel: LocationChannelsModel? = nil, + peerIdentityStore: PeerIdentityStore? = nil + ) { + self.chatViewModel = chatViewModel + self.conversationStore = conversationStore + self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel() + self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore + let initialPeerID = conversationStore.selectedPrivatePeerID + self.selectedPeerID = initialPeerID + self.selectedHeaderState = initialPeerID.flatMap { peerID in + makeHeaderState(for: peerID) + } + + bind() + } + + func startConversation(with peerID: PeerID) { + chatViewModel.startPrivateChat(with: peerID) + refreshSelectedConversation() + } + + func openConversation(for peerID: PeerID) { + if peerID.isGeoChat { + guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return } + chatViewModel.startGeohashDM(withPubkeyHex: full) + } else { + chatViewModel.startPrivateChat(with: peerID) + } + + refreshSelectedConversation() + } + + func endConversation() { + chatViewModel.endPrivateChat() + refreshSelectedConversation() + } + + func toggleFavorite(peerID: PeerID) { + chatViewModel.toggleFavorite(peerID: peerID) + refreshSelectedConversation() + } + + func toggleFavoriteForSelectedConversation() { + guard let headerPeerID = selectedHeaderState?.headerPeerID else { return } + toggleFavorite(peerID: headerPeerID) + } + + func markMessagesAsRead(from peerID: PeerID) { + chatViewModel.markPrivateMessagesAsRead(from: peerID) + } + + private func bind() { + conversationStore.$selectedPrivatePeerID + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + + chatViewModel.$allPeers + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + + peerIdentityStore.$encryptionStatuses + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .favoriteStatusChanged) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + + locationChannelsModel.$selectedChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshSelectedConversation() + } + .store(in: &cancellables) + } + + private func refreshSelectedConversation() { + selectedPeerID = conversationStore.selectedPrivatePeerID + selectedHeaderState = selectedPeerID.flatMap { peerID in + makeHeaderState(for: peerID) + } + } + + private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { + let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) + let peer = chatViewModel.getPeer(byID: headerPeerID) + let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) + let availability = resolveAvailability(for: headerPeerID, peer: peer) + let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM + ? nil + : chatViewModel.getEncryptionStatus(for: headerPeerID) + + return PrivateConversationHeaderState( + conversationPeerID: conversationPeerID, + headerPeerID: headerPeerID, + displayName: displayName, + availability: availability, + isFavorite: chatViewModel.isFavorite(peerID: headerPeerID), + encryptionStatus: encryptionStatus + ) + } + + private func resolveDisplayName( + for conversationPeerID: PeerID, + headerPeerID: PeerID, + peer: BitchatPeer? + ) -> String { + if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel { + return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))" + } + if let displayName = peer?.displayName { + return displayName + } + if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) { + return nickname + } + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus( + for: Data(hexString: headerPeerID.id) ?? Data() + ), !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + if headerPeerID.id.count == 16 { + let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let identity = candidates.first, + let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { + return pet + } + if !social.claimedNickname.isEmpty { + return social.claimedNickname + } + } + } else if let noiseKey = headerPeerID.noiseKey { + let fingerprint = noiseKey.sha256Fingerprint() + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { + return pet + } + if !social.claimedNickname.isEmpty { + return social.claimedNickname + } + } + } + + return String(localized: "common.unknown", comment: "Fallback label for unknown peer") + } + + private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability { + if let connectionState = peer?.connectionState { + switch connectionState { + case .bluetoothConnected: + return .bluetoothConnected + case .meshReachable: + return .meshReachable + case .nostrAvailable: + return .nostrAvailable + case .offline: + return .offline + } + } + + if chatViewModel.meshService.isPeerReachable(headerPeerID) { + return .meshReachable + } + if let noiseKey = Data(hexString: headerPeerID.id), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + favoriteStatus.isMutual { + return .nostrAvailable + } + if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) { + return .bluetoothConnected + } + + return .offline + } +} diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift new file mode 100644 index 00000000..3a5bcfe4 --- /dev/null +++ b/bitchat/App/PublicChatModel.swift @@ -0,0 +1,41 @@ +import BitFoundation +import Combine +import SwiftUI + +@MainActor +final class PublicChatModel: ObservableObject { + @Published private(set) var activeChannel: ChannelID + @Published private(set) var messages: [BitchatMessage] = [] + + private let conversationStore: ConversationStore + private var cancellables = Set() + + init(conversationStore: ConversationStore) { + self.activeChannel = conversationStore.activeChannel + self.conversationStore = conversationStore + + bind() + refreshMessages() + } + + private func bind() { + conversationStore.$activeChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] channel in + self?.activeChannel = channel + self?.refreshMessages() + } + .store(in: &cancellables) + + conversationStore.$messagesByConversation + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshMessages() + } + .store(in: &cancellables) + } + + private func refreshMessages() { + messages = conversationStore.messages(for: ConversationID(channelID: activeChannel)) + } +} diff --git a/bitchat/App/VerificationModel.swift b/bitchat/App/VerificationModel.swift new file mode 100644 index 00000000..bf31f05f --- /dev/null +++ b/bitchat/App/VerificationModel.swift @@ -0,0 +1,152 @@ +import BitFoundation +import Combine +import Foundation + +struct FingerprintPresentationState: Equatable { + let statusPeerID: PeerID + let peerNickname: String + let encryptionStatus: EncryptionStatus + let theirFingerprint: String? + let myFingerprint: String + let isVerified: Bool + + var canToggleVerification: Bool { + encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified + } +} + +enum VerificationScanOutcome: Equatable { + case requested(String) + case notFound + case invalid +} + +@MainActor +final class VerificationModel: ObservableObject { + @Published private(set) var currentNickname: String + @Published private(set) var selectedPeerID: PeerID? + + private let chatViewModel: ChatViewModel + private let peerIdentityStore: PeerIdentityStore + private var cancellables = Set() + + init( + chatViewModel: ChatViewModel, + privateConversationModel: PrivateConversationModel, + peerIdentityStore: PeerIdentityStore? = nil + ) { + self.chatViewModel = chatViewModel + self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore + self.currentNickname = chatViewModel.nickname + self.selectedPeerID = privateConversationModel.selectedPeerID + + bind(privateConversationModel: privateConversationModel) + } + + func myQRString() -> String { + let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub + return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? "" + } + + func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { + chatViewModel.beginQRVerification(with: qr) + } + + func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome { + guard let qr = VerificationService.shared.verifyScannedQR(payload) else { + return .invalid + } + + guard chatViewModel.beginQRVerification(with: qr) else { + return .notFound + } + + return .requested(qr.nickname) + } + + func verifyFingerprint(for peerID: PeerID) { + chatViewModel.verifyFingerprint(for: peerID) + } + + func unverifyFingerprint(for peerID: PeerID) { + chatViewModel.unverifyFingerprint(for: peerID) + } + + func isVerified(peerID: PeerID) -> Bool { + guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false } + return peerIdentityStore.isVerified(fingerprint) + } + + func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState { + let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID) + let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) + let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) + let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) + + return FingerprintPresentationState( + statusPeerID: statusPeerID, + peerNickname: peerNickname, + encryptionStatus: encryptionStatus, + theirFingerprint: theirFingerprint, + myFingerprint: chatViewModel.getMyFingerprint(), + isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false + ) + } + + private func bind(privateConversationModel: PrivateConversationModel) { + chatViewModel.$nickname + .receive(on: DispatchQueue.main) + .assign(to: &$currentNickname) + + privateConversationModel.$selectedPeerID + .receive(on: DispatchQueue.main) + .assign(to: &$selectedPeerID) + + peerIdentityStore.$encryptionStatuses + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + peerIdentityStore.$verifiedFingerprints + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + chatViewModel.$allPeers + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) + } + + private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { + if let peer = chatViewModel.getPeer(byID: statusPeerID) { + return peer.displayName + } + if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) { + return name + } + if let data = peerID.noiseKey { + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + let fingerprint = data.sha256Fingerprint() + if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { + return pet + } + if !social.claimedNickname.isEmpty { + return social.claimedNickname + } + } + } + + return String(localized: "common.unknown", comment: "Label for an unknown peer") + } +} diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 128ea1b7..928178cc 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -6,128 +6,55 @@ // For more information, see // -import Tor import SwiftUI -import BitFoundation import UserNotifications @main struct BitchatApp: App { static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat" static let groupID = "group.\(bundleID)" - - @StateObject private var chatViewModel: ChatViewModel + + @StateObject private var runtime: AppRuntime #if os(iOS) @Environment(\.scenePhase) var scenePhase @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - // Skip the very first .active-triggered Tor restart on cold launch - @State private var didHandleInitialActive: Bool = false - @State private var didEnterBackground: Bool = false #elseif os(macOS) @NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate #endif - - private let idBridge = NostrIdentityBridge() - + init() { - let keychain = KeychainManager() - let idBridge = self.idBridge - _chatViewModel = StateObject( - wrappedValue: ChatViewModel( - keychain: keychain, - idBridge: idBridge, - identityManager: SecureIdentityStateManager(keychain) - ) - ) - + _runtime = StateObject(wrappedValue: AppRuntime()) UNUserNotificationCenter.current().delegate = NotificationDelegate.shared - // Warm up georelay directory and refresh if stale (once/day) - GeoRelayDirectory.shared.prefetchIfNeeded() } - + var body: some Scene { WindowGroup { ContentView() - .environmentObject(chatViewModel) + .environmentObject(runtime.publicChatModel) + .environmentObject(runtime.privateInboxModel) + .environmentObject(runtime.privateConversationModel) + .environmentObject(runtime.verificationModel) + .environmentObject(runtime.conversationUIModel) + .environmentObject(runtime.locationChannelsModel) + .environmentObject(runtime.peerListModel) + .environmentObject(runtime.appChromeModel) .onAppear { - NotificationDelegate.shared.chatViewModel = chatViewModel - // Inject live Noise service into VerificationService to avoid creating new BLE instances - VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService()) - // Prewarm Nostr identity and QR to make first VERIFY sheet fast - let nickname = chatViewModel.nickname - DispatchQueue.global(qos: .utility).async { - let npub = try? idBridge.getCurrentNostrIdentity()?.npub - _ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub) - } - - appDelegate.chatViewModel = chatViewModel - - // Initialize network activation policy; will start Tor/Nostr only when allowed - NetworkActivationService.shared.start() - - // Start presence service (will wait for Tor readiness) - GeohashPresenceService.shared.start() - - // Check for shared content - checkForSharedContent() + appDelegate.runtime = runtime + runtime.start() } .onOpenURL { url in - handleURL(url) + runtime.handleOpenURL(url) } #if os(iOS) .onChange(of: scenePhase) { newPhase in - switch newPhase { - case .background: - // Keep BLE mesh running in background; BLEService adapts scanning automatically - // Always send Tor to dormant on background for a clean restart later. - TorManager.shared.setAppForeground(false) - TorManager.shared.goDormantOnBackground() - // Stop geohash sampling while backgrounded - Task { @MainActor in - chatViewModel.endGeohashSampling() - } - // Proactively disconnect Nostr to avoid spurious socket errors while Tor is down - NostrRelayManager.shared.disconnect() - didEnterBackground = true - case .active: - // Restart services when becoming active - chatViewModel.meshService.startServices() - TorManager.shared.setAppForeground(true) - // On initial cold launch, Tor was just started in onAppear. - // Skip the deterministic restart the first time we become active. - if didHandleInitialActive && didEnterBackground { - if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady { - TorManager.shared.ensureRunningOnForeground() - } - } else { - didHandleInitialActive = true - } - didEnterBackground = false - if TorManager.shared.isAutoStartAllowed() { - Task.detached { - let _ = await TorManager.shared.awaitReady(timeout: 60) - await MainActor.run { - // Rebuild proxied sessions to bind to the live Tor after readiness - TorURLSession.shared.rebuild() - // Reconnect Nostr via fresh sessions; will gate until Tor 100% - NostrRelayManager.shared.resetAllConnections() - } - } - } - checkForSharedContent() - case .inactive: - break - @unknown default: - break - } + runtime.handleScenePhaseChange(newPhase) } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - // Check for shared content when app becomes active - checkForSharedContent() + runtime.handleDidBecomeActiveNotification() } #elseif os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - // App became active + runtime.handleMacDidBecomeActiveNotification() } #endif } @@ -136,66 +63,18 @@ struct BitchatApp: App { .windowResizability(.contentSize) #endif } - - private func handleURL(_ url: URL) { - if url.scheme == "bitchat" && url.host == "share" { - // Handle shared content - checkForSharedContent() - } - } - - private func checkForSharedContent() { - // Check app group for shared content from extension - guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { - return - } - - guard let sharedContent = userDefaults.string(forKey: "sharedContent"), - let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else { - return - } - - // Only process if shared within configured window - if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds { - let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" - - // Clear the shared content - userDefaults.removeObject(forKey: "sharedContent") - userDefaults.removeObject(forKey: "sharedContentType") - userDefaults.removeObject(forKey: "sharedContentDate") - // No need to force synchronize here - - // Send the shared content immediately on the main queue - DispatchQueue.main.async { - if contentType == "url" { - // Try to parse as JSON first - if let data = sharedContent.data(using: .utf8), - let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String], - let url = urlData["url"] { - // Send plain URL - self.chatViewModel.sendMessage(url) - } else { - // Fallback to simple URL - self.chatViewModel.sendMessage(sharedContent) - } - } else { - self.chatViewModel.sendMessage(sharedContent) - } - } - } - } } #if os(iOS) final class AppDelegate: NSObject, UIApplicationDelegate { - weak var chatViewModel: ChatViewModel? - + weak var runtime: AppRuntime? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - return true + true } - + func applicationWillTerminate(_ application: UIApplication) { - chatViewModel?.applicationWillTerminate() + runtime?.applicationWillTerminate() } } #endif @@ -204,79 +83,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate { import AppKit final class MacAppDelegate: NSObject, NSApplicationDelegate { - weak var chatViewModel: ChatViewModel? - + weak var runtime: AppRuntime? + func applicationWillTerminate(_ notification: Notification) { - chatViewModel?.applicationWillTerminate() + runtime?.applicationWillTerminate() } - + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + true } } #endif final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationDelegate() - weak var chatViewModel: ChatViewModel? - + weak var runtime: AppRuntime? + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let identifier = response.notification.request.identifier let userInfo = response.notification.request.content.userInfo - - // Check if this is a private message notification - if identifier.hasPrefix("private-") { - // Get peer ID from userInfo - if let peerID = userInfo["peerID"] as? String { - DispatchQueue.main.async { - self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID)) - } - } + + Task { @MainActor in + self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo) } - // Handle deeplink (e.g., geohash activity) - if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) { - #if os(iOS) - DispatchQueue.main.async { UIApplication.shared.open(url) } - #else - DispatchQueue.main.async { NSWorkspace.shared.open(url) } - #endif - } - completionHandler() } - + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let identifier = notification.request.identifier let userInfo = notification.request.content.userInfo - - // Check if this is a private message notification - if identifier.hasPrefix("private-") { - // Get peer ID from userInfo - if let peerID = userInfo["peerID"] as? String { - // Don't show notification if the private chat is already open - // Access main-actor-isolated property via Task - Task { @MainActor in - if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) { - completionHandler([]) - } else { - completionHandler([.banner, .sound]) - } - } - return - } + + Task { + let options = await self.runtime?.presentationOptions( + forNotificationIdentifier: identifier, + userInfo: userInfo + ) ?? [.banner, .sound] + completionHandler(options) } - // Suppress geohash activity notification if we're already in that geohash channel - if identifier.hasPrefix("geo-activity-"), - let deep = userInfo["deeplink"] as? String, - let gh = deep.components(separatedBy: "/").last { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh { - completionHandler([]) - return - } - } - - // Show notification in all other cases - completionHandler([.banner, .sound]) } } - diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index ebfeadaa..f12d0e9e 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -121,13 +121,12 @@ final class NostrRelayManager: ObservableObject { var nextReconnectTime: Date? } - // Default relay list (can be customized) + // Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059. private static let defaultRelays = [ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", - "wss://offchain.pub", - "wss://nostr21.com" + "wss://offchain.pub" // For local testing, you can add: "ws://localhost:8080" ] private static let defaultRelaySet = Set(defaultRelays) @@ -143,10 +142,19 @@ final class NostrRelayManager: ObservableObject { private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var messageHandlers: [String: (NostrEvent) -> Void] = [:] - // Coalesce duplicate subscribe requests for the same id within a short window + // Coalesce duplicate subscribe requests for the same id within a short window. + private let subscribeCoalesceInterval: TimeInterval = 1.0 private var subscribeCoalesce: [String: Date] = [:] + private var pendingTorConnectionURLs = Set() + private var awaitingTorForConnections = false private var cancellables = Set() + private struct SubscriptionRequestState: Equatable { + let messageString: String + let relayURLs: Set + } + private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] + // Track EOSE per subscription to signal when initial stored events are done private struct EOSETracker { var pendingRelays: Set @@ -154,6 +162,7 @@ final class NostrRelayManager: ObservableObject { var timer: Timer? } private var eoseTrackers: [String: EOSETracker] = [:] + private var pendingEOSECallbacks: [String: () -> Void] = [:] // Message queue for reliability // Pending sends held only for relays that are not yet connected. @@ -233,25 +242,7 @@ final class NostrRelayManager: ObservableObject { func connect() { // Global network policy gate guard dependencies.activationAllowed() else { return } - if shouldUseTor { - // Ensure Tor is started early and wait for readiness off-main; then hop back to connect. - dependencies.awaitTorReady { [weak self] ready in - guard let self = self else { return } - if !ready { - SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) - return - } - SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) - for relay in self.relays { - self.connectToRelay(relay.url) - } - } - } else { - SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session) - for relay in self.relays { - connectToRelay(relay.url) - } - } + connectToRelays(relays.map(\.url), shouldLog: true) } /// Disconnect from all relays @@ -264,6 +255,14 @@ final class NostrRelayManager: ObservableObject { // Clear known subscriptions and any queued subs since connections are gone subscriptions.removeAll() pendingSubscriptions.removeAll() + subscriptionRequestState.removeAll() + pendingEOSECallbacks.removeAll() + for (_, tracker) in eoseTrackers { + tracker.timer?.invalidate() + } + eoseTrackers.removeAll() + pendingTorConnectionURLs.removeAll() + awaitingTorForConnections = false updateConnectionStatus() } @@ -273,22 +272,12 @@ final class NostrRelayManager: ObservableObject { guard dependencies.activationAllowed() else { return } let targets = allowedRelayList(from: relayUrls) guard !targets.isEmpty else { return } - if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer until Tor is fully ready; avoid queuing connection attempts early - dependencies.awaitTorReady { [weak self] ready in - guard let self = self else { return } - if ready { self.ensureConnections(to: relayUrls) } - } - return - } var existing = Set(relays.map { $0.url }) for url in targets where !existing.contains(url) { relays.append(Relay(url: url)) existing.insert(url) } - for url in targets where connections[url] == nil { - connectToRelay(url) - } + connectToRelays(targets) } /// Send an event to specified relays (or all if none specified) @@ -311,7 +300,7 @@ final class NostrRelayManager: ObservableObject { // Attempt immediate send to relays with active connections; queue the rest var stillPending = Set() for relayUrl in targetRelays { - if let connection = connections[relayUrl] { + if let connection = connectedConnection(for: relayUrl) { sendToRelay(event: event, connection: connection, relayUrl: relayUrl) } else { stillPending.insert(relayUrl) @@ -333,7 +322,7 @@ final class NostrRelayManager: ObservableObject { // Flush only for a specific relay for i in (0.. NostrRelayConnectionProtocol? { + guard let connection = connections[relayUrl], + relays.first(where: { $0.url == relayUrl })?.isConnected == true else { + return nil + } + return connection + } /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. func subscribe( @@ -372,24 +369,12 @@ final class NostrRelayManager: ObservableObject { ) { // Global network policy gate guard dependencies.activationAllowed() else { return } - // Coalesce rapid duplicate subscribe requests only if a handler already exists + // Coalesce rapid duplicate subscribe requests even while Tor readiness is pending. let now = dependencies.now() - if messageHandlers[id] != nil { - if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { - return - } - } - subscribeCoalesce[id] = now - if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer subscription setup until Tor is ready; avoid queuing subs early - dependencies.awaitTorReady { [weak self] ready in - guard let self = self else { return } - if ready { - self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE) - } - } + if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval { return } + subscribeCoalesce[id] = now messageHandlers[id] = handler let req = NostrRequest.subscribe(id: id, filters: [filter]) @@ -407,10 +392,17 @@ final class NostrRelayManager: ObservableObject { let baseUrls = relayUrls ?? Self.defaultRelays let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) } let urls = allowedRelayList(from: candidateUrls) + let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls)) + if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) { + return + } + subscriptionRequestState[id] = requestState + // Always queue subscriptions; sending happens when a relay reports connected - let existingSet = Set(relays.map { $0.url }) + var existingSet = Set(relays.map { $0.url }) for url in urls where !existingSet.contains(url) { relays.append(Relay(url: url)) + existingSet.insert(url) } for url in urls { var map = self.pendingSubscriptions[url] ?? [:] @@ -421,20 +413,10 @@ final class NostrRelayManager: ObservableObject { if let onEOSE = onEOSE { if urls.isEmpty { onEOSE() + } else if shouldWaitForTorBeforeConnecting { + pendingEOSECallbacks[id] = onEOSE } else { - var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) - // Fallback timeout to avoid hanging if a relay never sends EOSE - tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in - Task { @MainActor in - guard let self = self else { return } - if let t = self.eoseTrackers[id] { - t.timer?.invalidate() - self.eoseTrackers.removeValue(forKey: id) - onEOSE() - } - } - } - eoseTrackers[id] = tracker + startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE) } } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) @@ -506,6 +488,13 @@ final class NostrRelayManager: ObservableObject { messageHandlers.removeValue(forKey: id) // Allow immediate re-subscription by clearing coalescer timestamp subscribeCoalesce.removeValue(forKey: id) + subscriptionRequestState.removeValue(forKey: id) + pendingEOSECallbacks.removeValue(forKey: id) + eoseTrackers[id]?.timer?.invalidate() + eoseTrackers.removeValue(forKey: id) + for url in Array(pendingSubscriptions.keys) { + pendingSubscriptions[url]?.removeValue(forKey: id) + } let req = NostrRequest.close(id: id) let message = try? encoder.encode(req) @@ -525,6 +514,100 @@ final class NostrRelayManager: ObservableObject { } // MARK: - Private Methods + + private var shouldWaitForTorBeforeConnecting: Bool { + shouldUseTor && !dependencies.torIsReady() + } + + private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) { + guard dependencies.activationAllowed() else { return } + let targets = allowedRelayList(from: relayUrls).filter { + connections[$0] == nil && !isPermanentlyFailed($0) + } + guard !targets.isEmpty else { return } + + if shouldWaitForTorBeforeConnecting { + queueConnectionsUntilTorReady(targets) + return + } + + if shouldLog { + let route = shouldUseTor ? "via Tor" : "direct" + SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session) + } + + for url in targets { + connectToRelay(url) + } + } + + private func queueConnectionsUntilTorReady(_ relayUrls: [String]) { + let targets = allowedRelayList(from: relayUrls).filter { + connections[$0] == nil && !isPermanentlyFailed($0) + } + guard !targets.isEmpty else { return } + + pendingTorConnectionURLs.formUnion(targets) + guard !awaitingTorForConnections else { return } + + awaitingTorForConnections = true + let generation = connectionGeneration + dependencies.awaitTorReady { [weak self] ready in + guard let self else { return } + guard generation == self.connectionGeneration else { return } + + let pending = Array(self.pendingTorConnectionURLs) + self.pendingTorConnectionURLs.removeAll() + self.awaitingTorForConnections = false + + guard ready else { + SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) + return + } + + self.connectToRelays(pending, shouldLog: true) + } + } + + private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool { + guard !requestState.relayURLs.isEmpty else { return true } + return requestState.relayURLs.allSatisfy { url in + pendingSubscriptions[url]?[id] == requestState.messageString || + subscriptions[url]?.contains(id) == true + } + } + + private func startEOSETracking(id: String, relayURLs: Set, callback: @escaping () -> Void) { + eoseTrackers[id]?.timer?.invalidate() + var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil) + // Fallback timeout to avoid hanging if a relay never sends EOSE. + tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self else { return } + if let tracker = self.eoseTrackers[id] { + tracker.timer?.invalidate() + self.eoseTrackers.removeValue(forKey: id) + callback() + } + } + } + eoseTrackers[id] = tracker + } + + private func startPendingEOSETrackingIfNeeded(id: String) { + guard eoseTrackers[id] == nil, + let callback = pendingEOSECallbacks.removeValue(forKey: id), + let requestState = subscriptionRequestState[id] + else { + return + } + + if requestState.relayURLs.isEmpty { + callback() + } else { + startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback) + } + } private func connectToRelay(_ urlString: String) { // Global network policy gate @@ -550,12 +633,8 @@ final class NostrRelayManager: ObservableObject { // Attempting to connect to Nostr relay via the proxied session // If Tor is enforced but not ready, delay connection until it is. - if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - dependencies.awaitTorReady { [weak self] ready in - guard let self = self else { return } - if ready { self.connectToRelay(urlString) } - else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } - } + if shouldWaitForTorBeforeConnecting { + queueConnectionsUntilTorReady([urlString]) return } @@ -592,6 +671,7 @@ final class NostrRelayManager: ObservableObject { guard let connection = connections[relayUrl] else { return } for (id, messageString) in map { if self.subscriptions[relayUrl]?.contains(id) == true { continue } + startPendingEOSETrackingIfNeeded(id: id) connection.send(.string(messageString)) { error in if let error = error { SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session) @@ -671,9 +751,9 @@ final class NostrRelayManager: ObservableObject { } else { let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil if isGiftWrap { - SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) + SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session) } else { - SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session) + SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session) } } case .notice: diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index bba73aac..99b47288 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -28,6 +28,7 @@ struct CommandGeoParticipant { protocol CommandContextProvider: AnyObject { // MARK: - State Properties var nickname: String { get } + var activeChannel: ChannelID { get } var selectedPrivateChatPeer: PeerID? { get } var blockedUsers: Set { get } var privateChats: [PeerID: [BitchatMessage]] { get set } @@ -75,7 +76,7 @@ final class CommandProcessor { // Geohash context: disable favoriting in public geohash or GeoDM let inGeoPublic: Bool = { - switch LocationChannelManager.shared.selectedChannel { + switch contextProvider?.activeChannel ?? .mesh { case .mesh: return false case .location: return true } @@ -135,7 +136,7 @@ final class CommandProcessor { private func handleWho() -> CommandResult { // Show geohash participants when in a geohash channel; otherwise mesh peers - switch LocationChannelManager.shared.selectedChannel { + switch contextProvider?.activeChannel ?? .mesh { case .location(let ch): // Geohash context: show visible geohash participants (exclude self) guard let vm = contextProvider else { return .success(message: "nobody around") } diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift new file mode 100644 index 00000000..133e3f1c --- /dev/null +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -0,0 +1,92 @@ +import BitFoundation +import Foundation + +@MainActor +final class ChatComposerCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func updateAutocomplete(for text: String, cursorPosition: Int) { + let peerCandidates = autocompleteCandidates() + let (suggestions, range) = viewModel.autocompleteService.getSuggestions( + for: text, + peers: peerCandidates, + cursorPosition: cursorPosition + ) + + if !suggestions.isEmpty { + viewModel.autocompleteSuggestions = suggestions + viewModel.autocompleteRange = range + viewModel.showAutocomplete = true + viewModel.selectedAutocompleteIndex = 0 + } else { + viewModel.autocompleteSuggestions = [] + viewModel.autocompleteRange = nil + viewModel.showAutocomplete = false + viewModel.selectedAutocompleteIndex = 0 + } + } + + func completeNickname(_ nickname: String, in text: inout String) -> Int { + guard let range = viewModel.autocompleteRange else { return text.count } + + text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range) + + viewModel.showAutocomplete = false + viewModel.autocompleteSuggestions = [] + viewModel.autocompleteRange = nil + viewModel.selectedAutocompleteIndex = 0 + + return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) + } + + func parseMentions(from content: String) -> [String] { + let regex = ChatViewModel.Patterns.mention + let nsContent = content as NSString + let matches = regex.matches( + in: content, + options: [], + range: NSRange(location: 0, length: nsContent.length) + ) + + let peerNicknames = viewModel.meshService.getPeerNicknames() + var validTokens = Set(peerNicknames.values) + validTokens.insert(viewModel.nickname) + validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4))) + + var mentions: [String] = [] + for match in matches { + guard let range = Range(match.range(at: 1), in: content) else { continue } + let mentionedName = String(content[range]) + if validTokens.contains(mentionedName) { + mentions.append(mentionedName) + } + } + + return Array(Set(mentions)) + } +} + +private extension ChatComposerCoordinator { + func autocompleteCandidates() -> [String] { + switch viewModel.activeChannel { + case .mesh: + let values = viewModel.meshService.getPeerNicknames().values + return Array(values.filter { $0 != viewModel.meshService.myNickname }) + + case .location(let channel): + var tokens = Set() + for (pubkey, nick) in viewModel.geoNicknames { + tokens.insert("\(nick)#\(pubkey.suffix(4))") + } + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + tokens.remove(myToken) + } + return Array(tokens) + } + } +} diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift new file mode 100644 index 00000000..59f3ba74 --- /dev/null +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -0,0 +1,89 @@ +import BitFoundation +import BitLogger +import Foundation + +final class ChatDeliveryCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + @MainActor + func cleanupOldReadReceipts() { + guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else { + return + } + + let validMessageIDs = Set( + viewModel.privateChats.values.flatMap { messages in + messages.map(\.id) + } + ) + + let oldCount = viewModel.sentReadReceipts.count + viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs) + + let removedCount = oldCount - viewModel.sentReadReceipts.count + if removedCount > 0 { + SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) + } + } + + @MainActor + func didReceiveReadReceipt(_ receipt: ReadReceipt) { + updateMessageDeliveryStatus( + receipt.originalMessageID, + status: .read(by: receipt.readerNickname, at: receipt.timestamp) + ) + } + + @MainActor + func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + updateMessageDeliveryStatus(messageID, status: status) + } + + @MainActor + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + var didUpdateStatus = false + + if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) { + let currentStatus = viewModel.messages[index].deliveryStatus + if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { + viewModel.messages[index].deliveryStatus = status + didUpdateStatus = true + } + } + + var privateChats = viewModel.privateChats + for (peerID, chatMessages) in privateChats { + guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } + + let currentStatus = chatMessages[index].deliveryStatus + guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } + + let updatedMessages = chatMessages + updatedMessages[index].deliveryStatus = status + privateChats[peerID] = updatedMessages + didUpdateStatus = true + } + + if didUpdateStatus { + viewModel.privateChats = privateChats + viewModel.objectWillChange.send() + } + } +} + +private extension ChatDeliveryCoordinator { + func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { + guard let currentStatus else { return false } + + switch (currentStatus, newStatus) { + case (.read, .delivered), (.read, .sent): + return true + default: + return false + } + } +} diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift new file mode 100644 index 00000000..a74b6882 --- /dev/null +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -0,0 +1,260 @@ +import BitFoundation +import BitLogger +import Foundation + +@MainActor +final class ChatLifecycleCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func handleDidBecomeActive() { + if let bleService = viewModel.meshService as? BLEService { + let currentState = bleService.getCurrentBluetoothState() + viewModel.updateBluetoothState(currentState) + } + + guard let peerID = viewModel.selectedPrivateChatPeer else { return } + + markPrivateMessagesAsRead(from: peerID) + + let viewModel = self.viewModel + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in + Task { @MainActor in + viewModel?.markPrivateMessagesAsRead(from: peerID) + } + } + } + + func handleScreenshotCaptured() { + let screenshotMessage = "* \(viewModel.nickname) took a screenshot *" + + if let peerID = viewModel.selectedPrivateChatPeer { + sendPrivateScreenshotNotificationIfPossible( + screenshotMessage, + to: peerID + ) + appendPrivateScreenshotNotice(for: peerID) + return + } + + switch viewModel.activeChannel { + case .mesh: + viewModel.meshService.sendMessage( + screenshotMessage, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date() + ) + + case .location(let channel): + sendPublicGeohashScreenshotMessage( + screenshotMessage, + channel: channel + ) + } + + viewModel.addSystemMessage("you took a screenshot") + } + + func saveIdentityState() { + viewModel.identityManager.forceSave() + _ = viewModel.keychain.verifyIdentityKeyExists() + } + + func applicationWillTerminate() { + viewModel.meshService.stopServices() + saveIdentityState() + } + + func markPrivateMessagesAsRead(from peerID: PeerID) { + viewModel.privateChatManager.markAsRead(from: peerID) + viewModel.synchronizePrivateConversationStore() + + if peerID.isGeoDM, + let recipientHex = viewModel.nostrKeyMapping[peerID], + case .location(let channel) = viewModel.activeChannel, + let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + let messages = viewModel.privateChats[peerID] ?? [] + for message in messages where message.senderPeerID == peerID && !message.isRelay { + guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + + SecureLogger.debug( + "GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", + category: .session + ) + let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + nostrTransport.senderPeerID = viewModel.meshService.myPeerID + nostrTransport.sendReadReceiptGeohash( + message.id, + toRecipientHex: recipientHex, + from: identity + ) + viewModel.sentReadReceipts.insert(message.id) + } + return + } + + var noiseKeyHex: PeerID? + var peerNostrPubkey: String? + + if let noiseKey = Data(hexString: peerID.id), + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + noiseKeyHex = peerID + peerNostrPubkey = favoriteStatus.peerNostrPublicKey + } else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) + peerNostrPubkey = favoriteStatus?.peerNostrPublicKey + + if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) { + viewModel.unreadPrivateMessages.remove(noiseKeyHex) + } + } + + guard peerNostrPubkey != nil else { return } + + for message in getPrivateChatMessages(for: peerID) { + guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else { + continue + } + + guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + + let receipt = ReadReceipt( + originalMessageID: message.id, + readerID: viewModel.meshService.myPeerID, + readerNickname: viewModel.nickname + ) + let recipientPeerID = peerID.isHex + ? peerID + : (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) + + viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID) + viewModel.sentReadReceipts.insert(message.id) + } + } + + func getMessages(for peerID: PeerID?) -> [BitchatMessage] { + guard let peerID else { return viewModel.messages } + return getPrivateChatMessages(for: peerID) + } + + func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { + var combined: [BitchatMessage] = [] + + if let ephemeralMessages = viewModel.privateChats[peerID] { + combined.append(contentsOf: ephemeralMessages) + } + + if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) + if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] { + combined.append(contentsOf: stableMessages) + } + } + + var bestByID: [String: BitchatMessage] = [:] + for message in combined { + if let existing = bestByID[message.id] { + let existingRank = deliveryStatusRank(existing.deliveryStatus) + let candidateRank = deliveryStatusRank(message.deliveryStatus) + if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) { + bestByID[message.id] = message + } + } else { + bestByID[message.id] = message + } + } + + return bestByID.values.sorted { $0.timestamp < $1.timestamp } + } +} + +private extension ChatLifecycleCoordinator { + func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) { + guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return } + + let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + switch sessionState { + case .established: + viewModel.messageRouter.sendPrivate( + message, + to: peerID, + recipientNickname: peerNickname, + messageID: UUID().uuidString + ) + + case .none, .failed, .handshakeQueued, .handshaking: + SecureLogger.debug( + "Skipping screenshot notification to \(peerID) - no established session", + category: .security + ) + } + } + + func appendPrivateScreenshotNotice(for peerID: PeerID) { + let notice = BitchatMessage( + sender: "system", + content: "you took a screenshot", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.meshService.peerNickname(peerID: peerID), + senderPeerID: viewModel.meshService.myPeerID + ) + + var chats = viewModel.privateChats + if chats[peerID] == nil { + chats[peerID] = [] + } + chats[peerID]?.append(notice) + viewModel.privateChats = chats + } + + func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + + do { + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: message, + geohash: channel.geohash, + senderIdentity: identity, + nickname: viewModel.nickname, + teleported: viewModel.locationManager.teleported + ) + + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + + viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + } catch { + SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) + viewModel.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + } + } + } + + func deliveryStatusRank(_ status: DeliveryStatus?) -> Int { + guard let status else { return 0 } + switch status { + case .failed: return 1 + case .sending: return 2 + case .sent: return 3 + case .partiallyDelivered: return 4 + case .delivered: return 5 + case .read: return 6 + } + } +} diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift new file mode 100644 index 00000000..9a608052 --- /dev/null +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -0,0 +1,323 @@ +import BitFoundation +import BitLogger +import Foundation + +#if os(iOS) +import UIKit +#endif + +@MainActor +final class ChatMediaTransferCoordinator { + private enum MediaSendError: Error { + case encodingFailed + } + + private unowned let viewModel: ChatViewModel + + private(set) var transferIdToMessageIDs: [String: [String]] = [:] + private(set) var messageIDToTransferId: [String: String] = [:] + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func sendVoiceNote(at url: URL) { + guard viewModel.canSendMediaInCurrentContext else { + SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) + try? FileManager.default.removeItem(at: url) + viewModel.addSystemMessage("Voice notes are only available in mesh chats.") + return + } + + let targetPeer = viewModel.selectedPrivateChatPeer + let message = enqueueMediaMessage( + content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", + targetPeer: targetPeer + ) + let messageID = message.id + let transferId = makeTransferID(messageID: messageID) + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let fileSize = attributes[.size] as? Int, + fileSize <= FileTransferLimits.maxVoiceNoteBytes else { + let size = (attributes[.size] as? Int) ?? 0 + SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) + try? FileManager.default.removeItem(at: url) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") + } + return + } + + let data = try Data(contentsOf: url) + let packet = BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "audio/mp4", + content: data + ) + guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + + await MainActor.run { + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } else { + self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) + } + } + } catch { + SecureLogger.error("Voice note send failed: \(error)", category: .session) + await MainActor.run { + self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") + } + } + } + } + + #if os(iOS) + func processThenSendImage(_ image: UIImage?) { + guard let image else { return } + Task.detached { [weak self] in + guard let self else { return } + do { + let processedURL = try ImageUtils.processImage(image) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } + } + #elseif os(macOS) + func processThenSendImage(from url: URL?) { + guard let url else { return } + Task.detached { [weak self] in + guard let self else { return } + do { + let processedURL = try ImageUtils.processImage(at: url) + await MainActor.run { + self.sendImage(from: processedURL) + } + } catch { + SecureLogger.error("Image processing failed: \(error)", category: .session) + } + } + } + #endif + + func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { + guard viewModel.canSendMediaInCurrentContext else { + SecureLogger.info("Image send blocked outside mesh/private context", category: .session) + cleanup?() + viewModel.addSystemMessage("Images are only available in mesh chats.") + return + } + + let targetPeer = viewModel.selectedPrivateChatPeer + + Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } + var processedURL: URL? + do { + let outputURL = try ImageUtils.processImage(at: sourceURL) + processedURL = outputURL + let data = try Data(contentsOf: outputURL) + guard data.count <= FileTransferLimits.maxImageBytes else { + SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) + await MainActor.run { + self.viewModel.addSystemMessage("Image is too large to send.") + } + try? FileManager.default.removeItem(at: outputURL) + return + } + + let packet = BitchatFilePacket( + fileName: outputURL.lastPathComponent, + fileSize: UInt64(data.count), + mimeType: "image/jpeg", + content: data + ) + guard packet.encode() != nil else { throw MediaSendError.encodingFailed } + + await MainActor.run { + let message = self.enqueueMediaMessage( + content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", + targetPeer: targetPeer + ) + let messageID = message.id + let transferId = self.makeTransferID(messageID: messageID) + self.registerTransfer(transferId: transferId, messageID: messageID) + if let peerID = targetPeer { + self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } else { + self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) + } + } + } catch { + SecureLogger.error("Image send preparation failed: \(error)", category: .session) + await MainActor.run { + self.viewModel.addSystemMessage("Failed to prepare image for sending.") + } + if let processedURL { + try? FileManager.default.removeItem(at: processedURL) + } + } + } + } + + func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { + let timestamp = Date() + let message: BitchatMessage + + if let peerID = targetPeer { + message = BitchatMessage( + sender: viewModel.nickname, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nicknameForPeer(peerID), + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sending + ) + var chats = viewModel.privateChats + chats[peerID, default: []].append(message) + viewModel.privateChats = chats + viewModel.trimMessagesIfNeeded() + } else { + let (displayName, senderPeerID) = viewModel.currentPublicSender() + message = BitchatMessage( + sender: displayName, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: senderPeerID, + deliveryStatus: .sending + ) + viewModel.timelineStore.append(message, to: viewModel.activeChannel) + viewModel.refreshVisibleMessages(from: viewModel.activeChannel) + viewModel.trimMessagesIfNeeded() + } + + let key = viewModel.deduplicationService.normalizedContentKey(message.content) + viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) + viewModel.objectWillChange.send() + return message + } + + func registerTransfer(transferId: String, messageID: String) { + transferIdToMessageIDs[transferId, default: []].append(messageID) + messageIDToTransferId[messageID] = transferId + } + + func makeTransferID(messageID: String) -> String { + "\(messageID)-\(UUID().uuidString)" + } + + func clearTransferMapping(for messageID: String) { + guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } + guard var queue = transferIdToMessageIDs[transferId] else { return } + + if !queue.isEmpty { + if queue.first == messageID { + queue.removeFirst() + } else if let index = queue.firstIndex(of: messageID) { + queue.remove(at: index) + } + } + + transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue + } + + func handleMediaSendFailure(messageID: String, reason: String) { + viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) + clearTransferMapping(for: messageID) + } + + func handleTransferEvent(_ event: TransferProgressManager.Event) { + switch event { + case .started(let id, let total): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + case .updated(let id, let sent, let total): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + case .completed(let id, _): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + viewModel.updateMessageDeliveryStatus(messageID, status: .sent) + clearTransferMapping(for: messageID) + case .cancelled(let id, _, _): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + clearTransferMapping(for: messageID) + viewModel.removeMessage(withID: messageID, cleanupFile: true) + } + } + + func cleanupLocalFile(forMessage message: BitchatMessage) { + let categories: [MimeType.Category] = [.audio, .image, .file] + guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), + let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, + let base = try? applicationFilesDirectory(), + let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, + safeFilename != ".", + safeFilename != ".." else { + return + } + + let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } + for subdir in subdirs { + let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) + guard target.path.hasPrefix(base.path) else { continue } + + do { + try FileManager.default.removeItem(at: target) + } catch CocoaError.fileNoSuchFile { + continue + } catch { + SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session) + } + } + } + + func cancelMediaSend(messageID: String) { + if let transferId = messageIDToTransferId[messageID], + let active = transferIdToMessageIDs[transferId]?.first, + active == messageID { + viewModel.meshService.cancelTransfer(transferId) + } + clearTransferMapping(for: messageID) + viewModel.removeMessage(withID: messageID, cleanupFile: true) + } + + func deleteMediaMessage(messageID: String) { + clearTransferMapping(for: messageID) + viewModel.removeMessage(withID: messageID, cleanupFile: true) + } +} + +private extension ChatMediaTransferCoordinator { + func applicationFilesDirectory() throws -> URL { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let filesDirectory = base.appendingPathComponent("files", isDirectory: true) + try FileManager.default.createDirectory( + at: filesDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + return filesDirectory + } +} diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift new file mode 100644 index 00000000..21a63028 --- /dev/null +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -0,0 +1,503 @@ +import BitFoundation +import Foundation +import SwiftUI + +@MainActor +final class ChatMessageFormatter { + typealias Patterns = MessageFormattingEngine.Patterns + + private unowned let viewModel: ChatViewModel + private let meshPalette = MinimalDistancePalette(config: .mesh) + private let nostrPalette = MinimalDistancePalette(config: .nostr) + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isSelf: Bool = { + if let spid = message.senderPeerID { + if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { + let myGeo: NostrIdentity? = { + if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { + return cached.identity + } + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + viewModel.cachedGeohashIdentity = (channel.geohash, identity) + return identity + } + return nil + }() + if let myGeo { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == viewModel.meshService.myPeerID + } + if message.sender == viewModel.nickname { return true } + if message.sender.hasPrefix(viewModel.nickname + "#") { return true } + return false + }() + + let isDark = colorScheme == .dark + if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { + return cachedText + } + + var result = AttributedString() + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + + if message.sender != "system" { + let (baseName, suffix) = message.sender.splitSuffix() + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + let fontWeight: Font.Weight = isSelf ? .bold : .medium + senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) + if let spid = message.senderPeerID, + let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { + senderStyle.link = url + } + + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + + let content = message.content + let nsContent = content as NSString + let nsLen = nsContent.length + let containsCashuEarly: Bool = { + let regex = Patterns.quickCashuPresence + return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 + }() + + if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { + var plainStyle = AttributeContainer() + plainStyle.foregroundColor = baseColor + plainStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + result.append(AttributedString(content).mergingAttributes(plainStyle)) + } else { + let hashtagRegex = Patterns.hashtag + let mentionRegex = Patterns.mention + let cashuRegex = Patterns.cashu + let bolt11Regex = Patterns.bolt11 + let lnurlRegex = Patterns.lnurl + let lightningSchemeRegex = Patterns.lightningScheme + let detector = Patterns.linkDetector + let hasMentionsHint = content.contains("@") + let hasHashtagsHint = content.contains("#") + let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") + let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") + let hasCashuHint = content.lowercased().contains("cashu") + + let hashtagMatches = hasHashtagsHint + ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + let mentionMatches = hasMentionsHint + ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + let urlMatches = hasURLHint + ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) + : [] + let cashuMatches = hasCashuHint + ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + let lightningMatches = hasLightningHint + ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + let bolt11Matches = hasLightningHint + ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + let lnurlMatches = hasLightningHint + ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) + : [] + + let mentionRanges = mentionMatches.map { $0.range(at: 0) } + func overlapsMention(_ range: NSRange) -> Bool { + for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 { + return true + } + return false + } + + func attachedToMention(_ range: NSRange) -> Bool { + if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex { + var index = content.index(before: swiftRange.lowerBound) + while true { + let character = content[index] + if character.isWhitespace || character.isNewline { break } + if character == "@" { return true } + if index == content.startIndex { break } + index = content.index(before: index) + } + } + return false + } + + func isStandaloneHashtag(_ range: NSRange) -> Bool { + guard let swiftRange = Range(range, in: content) else { return false } + if swiftRange.lowerBound == content.startIndex { return true } + let previous = content.index(before: swiftRange.lowerBound) + return content[previous].isWhitespace || content[previous].isNewline + } + + var allMatches: [(range: NSRange, type: String)] = [] + for match in hashtagMatches + where !overlapsMention(match.range(at: 0)) + && !attachedToMention(match.range(at: 0)) + && isStandaloneHashtag(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "hashtag")) + } + for match in mentionMatches { + allMatches.append((match.range(at: 0), "mention")) + } + for match in urlMatches where !overlapsMention(match.range) { + allMatches.append((match.range, "url")) + } + for match in cashuMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "cashu")) + } + for match in lightningMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "lightning")) + } + + let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) } + func overlapsOccupied(_ range: NSRange) -> Bool { + for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 { + return true + } + return false + } + + for match in bolt11Matches + where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "bolt11")) + } + for match in lnurlMatches + where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append((match.range(at: 0), "lnurl")) + } + allMatches.sort { $0.range.location < $1.range.location } + + var lastEnd = content.startIndex + let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false + + for (range, type) in allMatches { + guard let swiftRange = Range(range, in: content) else { continue } + + if lastEnd < swiftRange.lowerBound { + let beforeText = String(content[lastEnd.. content.startIndex { + var index = content.index(before: swiftRange.lowerBound) + while true { + let character = content[index] + if character.isWhitespace || character.isNewline { break } + if character == "@" { return true } + if index == content.startIndex { break } + index = content.index(before: index) + } + } + return false + }() + let standalone: Bool = { + if swiftRange.lowerBound == content.startIndex { return true } + let previous = content.index(before: swiftRange.lowerBound) + return content[previous].isWhitespace || content[previous].isNewline + }() + + var tagStyle = AttributeContainer() + tagStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + tagStyle.foregroundColor = baseColor + if isGeohash && !attachedToMentionToken && standalone, + let url = URL(string: "bitchat://geohash/\(token)") { + tagStyle.link = url + tagStyle.underlineStyle = .single + } + result.append(AttributedString(matchText).mergingAttributes(tagStyle)) + } else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" { + var spacer = AttributeContainer() + spacer.foregroundColor = baseColor + spacer.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + result.append(AttributedString(" ").mergingAttributes(spacer)) + } else { + var matchStyle = AttributeContainer() + matchStyle.font = .bitchatSystem( + size: 14, + weight: isSelf ? .bold : .semibold, + design: .monospaced + ) + if type == "url" { + matchStyle.foregroundColor = isSelf ? .orange : .blue + matchStyle.underlineStyle = .single + if let url = URL(string: matchText) { + matchStyle.link = url + } + } + result.append(AttributedString(matchText).mergingAttributes(matchStyle)) + } + + if lastEnd < swiftRange.upperBound { + lastEnd = swiftRange.upperBound + } + } + + if lastEnd < content.endIndex { + let remainingText = String(content[lastEnd...]) + var remainingStyle = AttributeContainer() + remainingStyle.foregroundColor = baseColor + remainingStyle.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + if isMentioned { + remainingStyle.font = remainingStyle.font?.bold() + } + result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) + } + } + + let timestamp = AttributedString(" [\(message.formattedTimestamp)]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.7) + timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) + } else { + var contentStyle = AttributeContainer() + contentStyle.foregroundColor = Color.gray + let content = AttributedString("* \(message.content) *") + contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() + result.append(content.mergingAttributes(contentStyle)) + + let timestamp = AttributedString(" [\(message.formattedTimestamp)]") + var timestampStyle = AttributeContainer() + timestampStyle.foregroundColor = Color.gray.opacity(0.5) + timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) + result.append(timestamp.mergingAttributes(timestampStyle)) + } + + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) + return result + } + + func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { + let isSelf: Bool = { + if let spid = message.senderPeerID { + if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"), + let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + return spid == viewModel.meshService.myPeerID + } + if message.sender == viewModel.nickname { return true } + if message.sender.hasPrefix(viewModel.nickname + "#") { return true } + return false + }() + + let isDark = colorScheme == .dark + let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) + + if message.sender == "system" { + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + return AttributedString(message.sender).mergingAttributes(style) + } + + var result = AttributedString() + let (baseName, suffix) = message.sender.splitSuffix() + var senderStyle = AttributeContainer() + senderStyle.foregroundColor = baseColor + senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) + if let spid = message.senderPeerID, + let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { + senderStyle.link = url + } + + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = baseColor.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + result.append(AttributedString("> ").mergingAttributes(senderStyle)) + return result + } + + func isSelfMessage(_ message: BitchatMessage) -> Bool { + if let spid = message.senderPeerID { + if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { + let myGeo: NostrIdentity? = { + if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { + return cached.identity + } + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + viewModel.cachedGeohashIdentity = (channel.geohash, identity) + return identity + } + return nil + }() + if let myGeo { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == viewModel.meshService.myPeerID + } + if message.sender == viewModel.nickname { return true } + if message.sender.hasPrefix(viewModel.nickname + "#") { return true } + return false + } + + func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { + peerColor(for: message, isDark: isDark) + } + + func peerURL(for peerID: PeerID) -> URL? { + URL(string: "bitchat://user/\(peerID.toPercentEncoded())") + } + + func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { + getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) + } + + func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { + getPeerPaletteColor(for: peerID, isDark: isDark) + } +} + +private extension ChatMessageFormatter { + func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { + if let spid = message.senderPeerID { + if spid.isGeoChat || spid.isGeoDM { + let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() + return getNostrPaletteColor(for: full, isDark: isDark) + } else if spid.id.count == 16 { + return getPeerPaletteColor(for: spid, isDark: isDark) + } else { + return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) + } + } + return Color(peerSeed: message.sender.lowercased(), isDark: isDark) + } + + func meshSeed(for peerID: PeerID) -> String { + if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() { + return "noise:" + full + } + return peerID.id.lowercased() + } + + func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { + if peerID == viewModel.meshService.myPeerID { + return .orange + } + + meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) + if let color = meshPalette.color(for: peerID.id, isDark: isDark) { + return color + } + return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) + } + + func currentMeshPaletteSeeds() -> [String: String] { + let myID = viewModel.meshService.myPeerID + var seeds: [String: String] = [:] + for peer in viewModel.allPeers where peer.peerID != myID { + seeds[peer.peerID.id] = meshSeed(for: peer.peerID) + } + return seeds + } + + func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { + let myHex = currentGeohashIdentityHex() + if let myHex, pubkeyHexLowercased == myHex { + return .orange + } + + nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) + if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { + return color + } + return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) + } + + func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { + var seeds: [String: String] = [:] + let excluded = myHex ?? "" + for person in viewModel.visibleGeohashPeople() where person.id != excluded { + seeds[person.id] = "nostr:" + person.id + } + return seeds + } + + func currentGeohashIdentityHex() -> String? { + if case .location(let channel) = viewModel.activeChannel, + let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + return identity.publicKeyHex.lowercased() + } + return nil + } +} diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift new file mode 100644 index 00000000..091f7c87 --- /dev/null +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -0,0 +1,858 @@ +import BitFoundation +import BitLogger +import Foundation +import SwiftUI +import Tor + +final class ChatNostrCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + @MainActor + func resubscribeCurrentGeohash() { + guard case .location(let channel) = viewModel.activeChannel else { return } + guard let subID = viewModel.geoSubscriptionID else { + switchLocationChannel(to: viewModel.activeChannel) + return + } + + viewModel.participantTracker.startRefreshTimer() + NostrRelayManager.shared.unsubscribe(id: subID) + let filter = NostrFilter.geohashEphemeral( + channel.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.subscribeNostrEvent(event) + } + } + + if let dmSub = viewModel.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + viewModel.geoDmSubscriptionID = nil + } + + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + let dmSub = "geo-dm-\(channel.geohash)" + viewModel.geoDmSubscriptionID = dmSub + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.subscribeGiftWrap(giftWrap, id: identity) + } + } + } + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent) { + guard event.isValidSignature() else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), + !viewModel.deduplicationService.hasProcessedNostrEvent(event.id) + else { + return + } + + viewModel.deduplicationService.recordNostrEvent(event.id) + + if let gh = viewModel.currentGeohash, + let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1].trimmed + viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey) + } + + viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + let hasTeleportTag = event.tags.contains { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + } + + if hasTeleportTag { + let key = event.pubkey.lowercased() + let isSelf: Bool = { + if let gh = viewModel.currentGeohash, + let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { + return myIdentity.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + Task { @MainActor [weak viewModel] in + viewModel?.locationPresenceStore.markTeleported(key) + } + } + } + + let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) + let content = event.content.trimmed + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let timestamp = min(rawTs, Date()) + let mentions = viewModel.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) + viewModel.handlePublicMessage(message) + if !isBlocked { + viewModel.checkForMentions(message) + viewModel.sendHapticFeedback(for: message) + } + } + } + + @MainActor + func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard giftWrap.isValidSignature() else { return } + guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } + viewModel.deduplicationService.recordNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ), + let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let noisePayload = NoisePayload.decode(packet.payload) + else { + return + } + + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = PeerID(nostr_: senderPubkey) + viewModel.nostrKeyMapping[convKey] = senderPubkey + + switch noisePayload.type { + case .privateMessage: + viewModel.handlePrivateMessage( + noisePayload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func switchLocationChannel(to channel: ChannelID) { + viewModel.publicMessagePipeline.reset() + viewModel.activeChannel = channel + viewModel.publicMessagePipeline.updateActiveChannel(channel) + + viewModel.deduplicationService.clearNostrCaches() + switch channel { + case .mesh: + viewModel.refreshVisibleMessages(from: .mesh) + let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count + if emptyMesh > 0 { + SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) + } + viewModel.participantTracker.stopRefreshTimer() + viewModel.participantTracker.setActiveGeohash(nil) + viewModel.locationPresenceStore.clearTeleportedGeo() + + case .location: + viewModel.refreshVisibleMessages(from: channel) + } + + if case .location = channel { + for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() { + viewModel.addPublicSystemMessage(content) + } + } + + if let sub = viewModel.geoSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + viewModel.geoSubscriptionID = nil + } + if let dmSub = viewModel.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + viewModel.geoDmSubscriptionID = nil + } + viewModel.currentGeohash = nil + viewModel.participantTracker.setActiveGeohash(nil) + viewModel.locationPresenceStore.clearGeoNicknames() + + guard case .location(let channel) = channel else { return } + viewModel.currentGeohash = channel.geohash + viewModel.participantTracker.setActiveGeohash(channel.geohash) + + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + let hasRegional = !viewModel.locationManager.availableChannels.isEmpty + let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash } + let key = identity.publicKeyHex.lowercased() + if viewModel.locationManager.teleported && hasRegional && !inRegional { + viewModel.locationPresenceStore.markTeleported(key) + SecureLogger.info( + "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + category: .session + ) + } else { + viewModel.locationPresenceStore.clearTeleported(key) + } + } + + let subID = "geo-\(channel.geohash)" + viewModel.geoSubscriptionID = subID + viewModel.participantTracker.startRefreshTimer() + let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) + let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.handleNostrEvent(event) + } + } + + subscribeToGeoChat(channel) + } + + @MainActor + func handleNostrEvent(_ event: NostrEvent) { + guard event.isValidSignature() else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + + if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return } + viewModel.deduplicationService.recordNostrEvent(event.id) + + let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") + SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) + + if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } + + let hasTeleportTag = event.tags.contains { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + } + + let isSelf: Bool = { + if let gh = viewModel.currentGeohash, + let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == event.pubkey.lowercased() + } + return false + }() + + if hasTeleportTag, !isSelf { + let key = event.pubkey.lowercased() + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + viewModel.locationPresenceStore.markTeleported(key) + SecureLogger.info( + "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + category: .session + ) + } + } + + viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) + + if isSelf { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey) + } + + viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) + let content = event.content + + if let teleTag = event.tags.first(where: { $0.first == "t" }), + teleTag.count >= 2, + teleTag[1] == "teleport", + content.trimmed.isEmpty { + return + } + + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = viewModel.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: min(rawTs, Date()), + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + viewModel.handlePublicMessage(message) + viewModel.checkForMentions(message) + viewModel.sendHapticFeedback(for: message) + } + } + + @MainActor + func subscribeToGeoChat(_ channel: GeohashChannel) { + guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return } + + let dmSub = "geo-dm-\(channel.geohash)" + viewModel.geoDmSubscriptionID = dmSub + if TorManager.shared.isReady { + SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) + } + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.handleGiftWrap(giftWrap, id: identity) + } + } + } + + @MainActor + func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard giftWrap.isValidSignature() else { return } + if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { + return + } + viewModel.deduplicationService.recordNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ) else { + SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) + return + } + + SecureLogger.debug( + "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", + category: .session + ) + + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) + else { + return + } + + let convKey = PeerID(nostr_: senderPubkey) + viewModel.nostrKeyMapping[convKey] = senderPubkey + + switch payload.type { + case .privateMessage: + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + viewModel.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { + let channel = context.channel + let event = context.event + let identity = context.identity + + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + + viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex + SecureLogger.debug( + "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", + category: .session + ) + + let hasRegional = !viewModel.locationManager.availableChannels.isEmpty + let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash } + if context.teleported && hasRegional && !inRegional { + let key = identity.publicKeyHex.lowercased() + viewModel.locationPresenceStore.markTeleported(key) + SecureLogger.info( + "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + category: .session + ) + } + + viewModel.deduplicationService.recordNostrEvent(event.id) + } + + @MainActor + func beginGeohashSampling(for geohashes: [String]) { + if !TorManager.shared.isForeground() { + endGeohashSampling() + return + } + + let desired = Set(geohashes) + let current = Set(viewModel.geoSamplingSubs.values) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + + for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) { + NostrRelayManager.shared.unsubscribe(id: subID) + viewModel.geoSamplingSubs.removeValue(forKey: subID) + } + + for gh in toAdd { + subscribe(gh) + } + } + + @MainActor + func subscribe(_ gh: String) { + let subID = "geo-sample-\(gh)" + viewModel.geoSamplingSubs[subID] = gh + let filter = NostrFilter.geohashEphemeral( + gh, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), + limit: TransportConfig.nostrGeohashSampleLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.subscribeNostrEvent(event, gh: gh) + } + } + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent, gh: String) { + guard event.isValidSignature() else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + + let existingCount = viewModel.participantTracker.participantCount(for: gh) + viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) + + guard let content = event.content.trimmedOrNilIfEmpty else { return } + if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), + my.publicKeyHex.lowercased() == event.pubkey.lowercased() { + return + } + guard existingCount == 0 else { return } + + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } + + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return } + #elseif os(macOS) + guard NSApplication.shared.isActive else { return } + if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return } + #endif + + cooldownPerGeohash(gh, content: content, event: event) + } + + @MainActor + func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + let now = Date() + let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast + if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } + + let preview: String = { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + if content.count <= maxLen { return content } + let idx = content.index(content.startIndex, offsetBy: maxLen) + return String(content[.. Data? { + let favorites = FavoritesPersistenceService.shared.favorites.values + var npubToMatch = nostrPubkey + + if !nostrPubkey.hasPrefix("npub") { + if let pubkeyData = Data(hexString: nostrPubkey), + let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { + npubToMatch = encoded + } else { + SecureLogger.warning( + "⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", + category: .session + ) + } + } + + for relationship in favorites { + if let storedNostrKey = relationship.peerNostrPublicKey { + if storedNostrKey == npubToMatch { + return relationship.peerNoisePublicKey + } + if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { + SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) + return relationship.peerNoisePublicKey + } + } + } + + SecureLogger.debug( + "⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", + category: .session + ) + return nil + } + + @MainActor + func sendDeliveryAckViaNostrEmbedded( + _ message: BitchatMessage, + wasReadBefore: Bool, + senderPubkey: String, + key: Data? + ) { + if let _ = key { + if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity) + } + } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity) + SecureLogger.debug( + "Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", + category: .session + ) + } + + if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID { + if let _ = key { + if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) + } + } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) + SecureLogger.debug( + "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", + category: .session + ) + } + } + } + + @MainActor + func handleFavoriteNotification(content: String, from nostrPubkey: String) { + guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + + let isFavorite = content.contains("FAVORITE:TRUE") + let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" + + if isFavorite { + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: nostrPubkey, + peerNickname: senderNickname + ) + } + + var extractedNostrPubkey: String? + if let range = content.range(of: "NPUB:") { + let suffix = content[range.upperBound...] + let parts = suffix.components(separatedBy: "|") + if let key = parts.first { + extractedNostrPubkey = String(key) + } + } else if content.contains(":") { + let parts = content.components(separatedBy: ":") + if parts.count >= 3 { + extractedNostrPubkey = String(parts[2]) + } + } + + SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) + + if isFavorite && extractedNostrPubkey != nil { + SecureLogger.info( + "💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", + category: .session + ) + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: senderNoiseKey, + peerNostrPublicKey: extractedNostrPubkey, + peerNickname: senderNickname + ) + } + + NotificationService.shared.sendLocalNotification( + title: isFavorite ? "New Favorite" : "Favorite Removed", + body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", + identifier: "fav-\(UUID().uuidString)" + ) + } + + @MainActor + func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { + guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), + relationship.peerNostrPublicKey != nil else { + SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) + return + } + + let peerID = PeerID(hexData: noisePublicKey) + viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + } + + @MainActor + func nostrPubkeyForDisplayName(_ name: String) -> String? { + for person in viewModel.visibleGeohashPeople() where person.displayName == name { + return person.id + } + for (pub, nick) in viewModel.geoNicknames where nick == name { + return pub + } + return nil + } + + @MainActor + func startGeohashDM(withPubkeyHex hex: String) { + let convKey = PeerID(nostr_: hex) + viewModel.nostrKeyMapping[convKey] = hex + viewModel.startPrivateChat(with: convKey) + } + + @MainActor + func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { + viewModel.nostrKeyMapping[senderID] + } + + @MainActor + func geohashDisplayName(for convKey: PeerID) -> String { + guard let full = viewModel.nostrKeyMapping[convKey] else { + return convKey.bare + } + return viewModel.displayNameForNostrPubkey(full) + } +} + +private extension ChatNostrCoordinator { + @MainActor + static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { + guard content.hasPrefix("bitchat1:") else { return nil } + let encoded = String(content.dropFirst("bitchat1:".count)) + let maxBytes = FileTransferLimits.maxFramedFileBytes + let maxEncoded = ((maxBytes + 2) / 3) * 4 + guard encoded.count <= maxEncoded else { return nil } + guard let packetData = ChatViewModel.base64URLDecode(encoded), + packetData.count <= maxBytes + else { + return nil + } + return BitchatPacket.from(packetData) + } +} diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift new file mode 100644 index 00000000..555f9fc5 --- /dev/null +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -0,0 +1,151 @@ +import BitFoundation +import BitLogger +import Foundation + +@MainActor +final class ChatOutgoingCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func sendMessage(_ content: String) { + guard let trimmed = content.trimmedOrNilIfEmpty else { return } + + if content.hasPrefix("/") { + Task { @MainActor [weak viewModel] in + viewModel?.handleCommand(content) + } + return + } + + if viewModel.selectedPrivateChatPeer != nil { + viewModel.updatePrivateChatPeerIfNeeded() + + if let selectedPeer = viewModel.selectedPrivateChatPeer { + viewModel.sendPrivateMessage(content, to: selectedPeer) + } + return + } + + let mentions = viewModel.parseMentions(from: content) + let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) + guard let preparedMessage else { return } + + appendLocalEcho(preparedMessage.message) + routePublicMessage( + originalContent: content, + mentions: mentions, + geoContext: preparedMessage.geoContext, + messageID: preparedMessage.message.id, + timestamp: preparedMessage.message.timestamp + ) + } +} + +private extension ChatOutgoingCoordinator { + func preparePublicMessage( + content: String, + trimmed: String, + mentions: [String] + ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { + var geoContext: ChatViewModel.GeoOutgoingContext? + var displaySender = viewModel.nickname + var localSenderPeerID = viewModel.meshService.myPeerID + var messageID: String? + var messageTimestamp = Date() + + switch viewModel.activeChannel { + case .mesh: + break + + case .location(let channel): + do { + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = viewModel.nickname + "#" + suffix + localSenderPeerID = PeerID(nostr: identity.publicKeyHex) + + let teleported = viewModel.locationManager.teleported + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: trimmed, + geohash: channel.geohash, + senderIdentity: identity, + nickname: viewModel.nickname, + teleported: teleported + ) + + messageID = event.id + messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + geoContext = ( + channel: channel, + event: event, + identity: identity, + teleported: teleported + ) + } catch { + SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) + viewModel.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return nil + } + } + + let message = BitchatMessage( + id: messageID, + sender: displaySender, + content: trimmed, + timestamp: messageTimestamp, + isRelay: false, + senderPeerID: localSenderPeerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + return (message, geoContext) + } + + func appendLocalEcho(_ message: BitchatMessage) { + viewModel.timelineStore.append(message, to: viewModel.activeChannel) + viewModel.refreshVisibleMessages(from: viewModel.activeChannel) + + let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content) + viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp) + viewModel.trimMessagesIfNeeded() + } + + func routePublicMessage( + originalContent: String, + mentions: [String], + geoContext: ChatViewModel.GeoOutgoingContext?, + messageID: String, + timestamp: Date + ) { + switch viewModel.activeChannel { + case .mesh: + viewModel.lastPublicActivityAt["mesh"] = Date() + viewModel.meshService.sendMessage( + originalContent, + mentions: mentions, + messageID: messageID, + timestamp: timestamp + ) + + case .location(let channel): + viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date() + + guard let geoContext, geoContext.channel.geohash == channel.geohash else { + SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) + viewModel.addSystemMessage( + String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") + ) + return + } + + Task { @MainActor [weak viewModel] in + viewModel?.sendGeohash(context: geoContext) + } + } + } +} diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift new file mode 100644 index 00000000..4bb4585d --- /dev/null +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -0,0 +1,379 @@ +import BitFoundation +import BitLogger +import CoreBluetooth +import Foundation + +final class ChatPeerIdentityCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + @MainActor + func openMostRelevantPrivateChat() { + let unreadSorted = viewModel.unreadPrivateMessages + .map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + .sorted { $0.1 > $1.1 } + if let target = unreadSorted.first?.0 { + startPrivateChat(with: target) + return + } + + let recent = viewModel.privateChats + .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } + .sorted { $0.ts > $1.ts } + if let target = recent.first?.id { + startPrivateChat(with: target) + } + } + + @MainActor + func isPeerBlocked(_ peerID: PeerID) -> Bool { + viewModel.unifiedPeerService.isBlocked(peerID) + } + + @MainActor + func updatePrivateChatPeerIfNeeded() { + guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint, + let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else { + return + } + + if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID { + migrateChatState(from: oldPeerID, to: currentPeerID) + viewModel.selectedPrivateChatPeer = currentPeerID + } else if viewModel.selectedPrivateChatPeer == nil { + viewModel.selectedPrivateChatPeer = currentPeerID + } + + var unread = viewModel.unreadPrivateMessages + unread.remove(currentPeerID) + viewModel.unreadPrivateMessages = unread + } + + @MainActor + func startPrivateChat(with peerID: PeerID) { + guard peerID != viewModel.meshService.myPeerID else { return } + + let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown" + + if viewModel.unifiedPeerService.isBlocked(peerID) { + viewModel.addSystemMessage( + String( + format: String( + localized: "system.chat.blocked", + comment: "System message when starting chat fails because peer is blocked" + ), + locale: .current, + peerNickname + ) + ) + return + } + + if let peer = viewModel.unifiedPeerService.getPeer(by: peerID), + peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { + viewModel.addSystemMessage( + String( + format: String( + localized: "system.chat.requires_favorite", + comment: "System message when mutual favorite requirement blocks chat" + ), + locale: .current, + peerNickname + ) + ) + return + } + + _ = viewModel.privateChatManager.consolidateMessages( + for: peerID, + peerNickname: peerNickname, + persistedReadReceipts: viewModel.sentReadReceipts + ) + + if !peerID.isGeoDM && !peerID.isGeoChat { + switch viewModel.meshService.getNoiseSessionState(for: peerID) { + case .none, .failed: + viewModel.meshService.triggerHandshake(with: peerID) + case .handshakeQueued, .handshaking, .established: + break + } + } else { + SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) + } + + viewModel.privateChatManager.syncReadReceiptsForSentMessages( + peerID: peerID, + nickname: viewModel.nickname, + externalReceipts: &viewModel.sentReadReceipts + ) + + if let fingerprint = getFingerprint(for: peerID) { + viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID) + viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + } else { + viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + } + viewModel.privateChatManager.startChat(with: peerID) + viewModel.synchronizePrivateConversationStore() + viewModel.synchronizeConversationSelectionStore() + viewModel.markPrivateMessagesAsRead(from: peerID) + } + + @MainActor + func endPrivateChat() { + viewModel.selectedPrivateChatPeer = nil + viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + } + + @MainActor + func handlePeerStatusUpdate() { + updatePrivateChatPeerIfNeeded() + } + + func handleFavoriteStatusChanged(_ notification: Notification) { + guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } + + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + + if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, + isKeyUpdate, + let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { + migrateNoiseKeyUpdate( + oldPeerID: PeerID(hexData: oldKey), + newPeerID: PeerID(hexData: peerPublicKey) + ) + } + + updatePrivateChatPeerIfNeeded() + + if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { + let peerID = PeerID(hexData: peerPublicKey) + let action = isFavorite ? "favorited" : "unfavorited" + let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey) + viewModel.addSystemMessage("\(peerNickname) \(action) you") + } + } + } + + @MainActor + func updateEncryptionStatusForPeers() { + for peerID in viewModel.connectedPeers { + updateEncryptionStatus(for: peerID) + } + } + + @MainActor + func updateEncryptionStatus(for peerID: PeerID) { + let noiseService = viewModel.meshService.getNoiseService() + + if noiseService.hasEstablishedSession(with: peerID) { + viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) + } else if noiseService.hasSession(with: peerID) { + viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) + } else { + viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID) + } + + invalidateEncryptionCache(for: peerID) + } + + @MainActor + func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { + if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) { + return cachedStatus + } + + let hasEverEstablishedSession = getFingerprint(for: peerID) != nil + let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + + let status: EncryptionStatus + switch sessionState { + case .established: + status = verifiedEncryptionStatus(for: peerID) + case .handshaking, .handshakeQueued: + status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking + case .none: + status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake + case .failed: + status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none + } + + viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) + return status + } + + @MainActor + func invalidateEncryptionCache(for peerID: PeerID? = nil) { + viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID) + } + + @MainActor + func getFingerprint(for peerID: PeerID) -> String? { + viewModel.unifiedPeerService.getFingerprint(for: peerID) + } + + @MainActor + func resolveNickname(for peerID: PeerID) -> String { + guard !peerID.isEmpty else { return "unknown" } + + if !peerID.isHex { + return peerID.id + } + + if let nickname = viewModel.meshService.getPeerNicknames()[peerID] { + return nickname + } + + if let fingerprint = getFingerprint(for: peerID), + let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) { + if let petname = identity.localPetname { + return petname + } + return identity.claimedNickname + } + + let prefixLength = min(4, peerID.id.count) + let prefix = String(peerID.id.prefix(prefixLength)) + return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)" + } + + @MainActor + func getMyFingerprint() -> String { + viewModel.meshService.getNoiseService().getIdentityFingerprint() + } + + @MainActor + func getPeerIDForNickname(_ nickname: String) -> PeerID? { + switch viewModel.activeChannel { + case .location: + if nickname.contains("#"), + let person = viewModel.publicConversationCoordinator + .visibleGeohashPeople() + .first(where: { $0.displayName == nickname }) { + let conversationKey = PeerID(nostr_: person.id) + viewModel.nostrKeyMapping[conversationKey] = person.id + return conversationKey + } + + let base = nickname + .split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false) + .first + .map(String.init)? + .lowercased() ?? nickname.lowercased() + if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { + let conversationKey = PeerID(nostr_: pubkey) + viewModel.nostrKeyMapping[conversationKey] = pubkey + return conversationKey + } + + case .mesh: + break + } + + return viewModel.unifiedPeerService.getPeerID(for: nickname) + } + + @MainActor + func nicknameForPeer(_ peerID: PeerID) -> String { + if let name = viewModel.meshService.peerNickname(peerID: peerID) { + return name + } + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + if let noiseKey = Data(hexString: peerID.id), + let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + !favorite.peerNickname.isEmpty { + return favorite.peerNickname + } + return "user" + } +} + +private extension ChatPeerIdentityCoordinator { + @MainActor + func currentPeerID(forFingerprint fingerprint: String) -> PeerID? { + for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint { + return peerID + } + return nil + } + + @MainActor + func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { + if let oldMessages = viewModel.privateChats[oldPeerID] { + var chats = viewModel.privateChats + chats[newPeerID, default: []].append(contentsOf: oldMessages) + chats[newPeerID]?.sort { $0.timestamp < $1.timestamp } + + var seenMessageIDs = Set() + chats[newPeerID] = chats[newPeerID]?.filter { message in + if seenMessageIDs.contains(message.id) { + return false + } + seenMessageIDs.insert(message.id) + return true + } + + chats.removeValue(forKey: oldPeerID) + viewModel.privateChats = chats + } + + var unread = viewModel.unreadPrivateMessages + if unread.contains(oldPeerID) { + unread.remove(oldPeerID) + unread.insert(newPeerID) + viewModel.unreadPrivateMessages = unread + } + } + + @MainActor + func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { + if viewModel.selectedPrivateChatPeer == oldPeerID { + SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) + } else if viewModel.privateChats[oldPeerID] != nil { + SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) + } + + migrateChatState(from: oldPeerID, to: newPeerID) + + if viewModel.selectedPrivateChatPeer == oldPeerID { + viewModel.selectedPrivateChatPeer = newPeerID + } + + if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping( + from: oldPeerID, + to: newPeerID, + fallback: getFingerprint(for: newPeerID) + ) { + if viewModel.selectedPrivateChatPeer == newPeerID { + viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + } + } + } + + @MainActor + func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String { + if let nickname = viewModel.meshService.peerNickname(peerID: peerID) { + return nickname + } + if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { + return favorite.peerNickname + } + return "Unknown" + } + + @MainActor + func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { + if let fingerprint = getFingerprint(for: peerID), + viewModel.peerIdentityStore.isVerified(fingerprint) { + return .noiseVerified + } + return .noiseSecured + } +} diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift new file mode 100644 index 00000000..80a24f2b --- /dev/null +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -0,0 +1,186 @@ +import BitFoundation +import BitLogger +import Foundation + +final class ChatPeerListCoordinator: @unchecked Sendable { + private unowned let viewModel: ChatViewModel + private var recentlySeenPeers: Set = [] + private var lastNetworkNotificationTime = Date.distantPast + private var networkResetTimer: Timer? + private var networkEmptyTimer: Timer? + private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + deinit { + networkResetTimer?.invalidate() + networkEmptyTimer?.invalidate() + } + + func didUpdatePeerList(_ peers: [PeerID]) { + Task { @MainActor [weak self] in + self?.handlePeerListUpdate(peers) + } + } +} + +private extension ChatPeerListCoordinator { + @MainActor + func handlePeerListUpdate(_ peers: [PeerID]) { + viewModel.isConnected = !peers.isEmpty + cleanupStaleUnreadPeerIDs() + + let meshPeers = peers.filter { peerID in + viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID) + } + + handleNetworkAvailability(meshPeers) + + for peerID in peers { + viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + } + + viewModel.updateEncryptionStatusForPeers() + + if viewModel.hasTrackedPrivateChatSelection { + viewModel.updatePrivateChatPeerIfNeeded() + } + } + + @MainActor + func handleNetworkAvailability(_ meshPeers: [PeerID]) { + let meshPeerSet = Set(meshPeers) + + if meshPeerSet.isEmpty { + scheduleNetworkEmptyTimer() + return + } + + invalidateNetworkEmptyTimer() + + let newPeers = meshPeerSet.subtracting(recentlySeenPeers) + guard !newPeers.isEmpty else { return } + + let cooldown = TransportConfig.networkNotificationCooldownSeconds + if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown { + recentlySeenPeers.formUnion(newPeers) + lastNetworkNotificationTime = Date() + NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + SecureLogger.info( + "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", + category: .session + ) + } + + scheduleNetworkResetTimer() + } + + @MainActor + func cleanupStaleUnreadPeerIDs() { + let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID)) + let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs) + + guard !staleIDs.isEmpty else { + viewModel.cleanupOldReadReceipts() + return + } + + var idsToRemove: [PeerID] = [] + + for staleID in staleIDs { + if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + continue + } + + if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + continue + } + + idsToRemove.append(staleID) + viewModel.unreadPrivateMessages.remove(staleID) + } + + if !idsToRemove.isEmpty { + SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) + } + + viewModel.cleanupOldReadReceipts() + } + + @MainActor + func scheduleNetworkResetTimer() { + networkResetTimer?.invalidate() + networkResetTimer = Timer.scheduledTimer(withTimeInterval: networkResetGraceSeconds, repeats: false) { [weak self] _ in + MainActor.assumeIsolated { [weak self] in + self?.handleNetworkResetTimerFired() + } + } + } + + @MainActor + func handleNetworkResetTimerFired() { + let activeMeshPeers = viewModel.meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) + } + + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) + } else { + SecureLogger.debug( + "⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", + category: .session + ) + } + + networkResetTimer = nil + } + + @MainActor + func scheduleNetworkEmptyTimer() { + guard networkEmptyTimer == nil else { return } + + networkEmptyTimer = Timer.scheduledTimer( + withTimeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, + repeats: false + ) { [weak self] _ in + MainActor.assumeIsolated { [weak self] in + self?.handleNetworkEmptyTimerFired() + } + } + + SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) + } + + @MainActor + func invalidateNetworkEmptyTimer() { + guard networkEmptyTimer != nil else { return } + networkEmptyTimer?.invalidate() + networkEmptyTimer = nil + } + + @MainActor + func handleNetworkEmptyTimerFired() { + let activeMeshPeers = viewModel.meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) + } + + if activeMeshPeers.isEmpty { + recentlySeenPeers.removeAll() + SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) + } else { + SecureLogger.debug( + "⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", + category: .session + ) + } + + networkEmptyTimer = nil + } +} diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift new file mode 100644 index 00000000..bd7ea131 --- /dev/null +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -0,0 +1,714 @@ +import BitFoundation +import BitLogger +import Foundation + +@MainActor +final class ChatPrivateConversationCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func sendPrivateMessage(_ content: String, to peerID: PeerID) { + guard !content.isEmpty else { return } + + if viewModel.unifiedPeerService.isBlocked(peerID) { + let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user" + viewModel.addSystemMessage( + String( + format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), + locale: .current, + nickname + ) + ) + return + } + + if peerID.isGeoDM { + sendGeohashDM(content, to: peerID) + return + } + + guard let noiseKey = Data(hexString: peerID.id) else { return } + let isConnected = viewModel.meshService.isPeerConnected(peerID) + let isReachable = viewModel.meshService.isPeerReachable(peerID) + let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + let isMutualFavorite = favoriteStatus?.isMutual ?? false + let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil + + var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID) + if recipientNickname == nil && favoriteStatus != nil { + recipientNickname = favoriteStatus?.peerNickname + } + recipientNickname = recipientNickname ?? "user" + + let messageID = UUID().uuidString + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: recipientNickname, + senderPeerID: viewModel.meshService.myPeerID, + mentions: nil, + deliveryStatus: .sending + ) + + if viewModel.privateChats[peerID] == nil { + viewModel.privateChats[peerID] = [] + } + viewModel.privateChats[peerID]?.append(message) + viewModel.objectWillChange.send() + + if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { + viewModel.messageRouter.sendPrivate( + content, + to: peerID, + recipientNickname: recipientNickname ?? "user", + messageID: messageID + ) + if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent + } + } else { + if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[index].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") + ) + } + let name = recipientNickname ?? "user" + viewModel.addSystemMessage( + String( + format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), + locale: .current, + name + ) + ) + } + } + + func sendGeohashDM(_ content: String, to peerID: PeerID) { + guard case .location(let channel) = viewModel.activeChannel else { + viewModel.addSystemMessage( + String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") + ) + return + } + + let messageID = UUID().uuidString + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: content, + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: viewModel.meshService.myPeerID, + deliveryStatus: .sending + ) + + if viewModel.privateChats[peerID] == nil { + viewModel.privateChats[peerID] = [] + } + + viewModel.privateChats[peerID]?.append(message) + viewModel.objectWillChange.send() + + guard let recipientHex = viewModel.nostrKeyMapping[peerID] else { + if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") + ) + } + return + } + + if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { + if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") + ) + } + viewModel.addSystemMessage( + String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") + ) + return + } + + do { + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + if recipientHex.lowercased() == identity.publicKeyHex.lowercased() { + if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") + ) + } + return + } + + SecureLogger.debug( + "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", + category: .session + ) + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendPrivateMessageGeohash( + content: content, + toRecipientHex: recipientHex, + from: identity, + messageID: messageID + ) + if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent + } + } catch { + if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( + reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") + ) + } + } + } + + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date + ) { + guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } + let messageId = pm.messageID + + SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) + + sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + + if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { + return + } + + if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } + for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) { + return + } + + let senderName = viewModel.displayNameForNostrPubkey(senderPubkey) + let message = BitchatMessage( + id: messageId, + sender: senderName, + content: pm.content, + timestamp: messageTimestamp, + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: convKey, + deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) + ) + + if viewModel.privateChats[convKey] == nil { + viewModel.privateChats[convKey] = [] + } + viewModel.privateChats[convKey]?.append(message) + + let isViewing = viewModel.selectedPrivateChatPeer == convKey + let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage + if shouldMarkUnread { + viewModel.unreadPrivateMessages.insert(convKey) + } + + if isViewing { + sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) + } + + if !isViewing && shouldMarkUnread { + NotificationService.shared.sendPrivateMessageNotification( + from: senderName, + message: pm.content, + peerID: convKey + ) + } + + viewModel.objectWillChange.send() + } + + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { + guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + + if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered( + to: viewModel.displayNameForNostrPubkey(senderPubkey), + at: Date() + ) + viewModel.objectWillChange.send() + SecureLogger.info( + "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: .session + ) + } else { + SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) + } + } + + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { + guard let messageID = String(data: payload.data, encoding: .utf8) else { return } + + if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + viewModel.privateChats[convKey]?[idx].deliveryStatus = .read( + by: viewModel.displayNameForNostrPubkey(senderPubkey), + at: Date() + ) + viewModel.objectWillChange.send() + SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) + } else { + SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) + } + } + + func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return } + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) + viewModel.sentGeoDeliveryAcks.insert(messageId) + } + + func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { + guard !viewModel.sentReadReceipts.contains(messageId) else { return } + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) + viewModel.sentReadReceipts.insert(messageId) + } + + func handlePrivateMessage( + _ payload: NoisePayload, + actualSenderNoiseKey: Data?, + senderNickname: String, + targetPeerID: PeerID, + messageTimestamp: Date, + senderPubkey: String + ) { + guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } + let messageId = pm.messageID + let messageContent = pm.content + + if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { + if let key = actualSenderNoiseKey { + handleFavoriteNotificationFromMesh( + messageContent, + from: PeerID(hexData: key), + senderNickname: senderNickname + ) + } + return + } + + if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { + return + } + + let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) + + var isViewingThisChat = false + if viewModel.selectedPrivateChatPeer == targetPeerID { + isViewingThisChat = true + } else if let selectedPeer = viewModel.selectedPrivateChatPeer, + let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer), + let key = actualSenderNoiseKey, + selectedPeerData.noisePublicKey == key { + isViewingThisChat = true + } + + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage + + let message = BitchatMessage( + id: messageId, + sender: senderNickname, + content: messageContent, + timestamp: messageTimestamp, + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: targetPeerID, + deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) + ) + + addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) + mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) + + viewModel.sendDeliveryAckViaNostrEmbedded( + message, + wasReadBefore: wasReadBefore, + senderPubkey: senderPubkey, + key: actualSenderNoiseKey + ) + + if wasReadBefore { + // No-op. + } else if isViewingThisChat { + handleViewingThisChat( + message, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + senderPubkey: senderPubkey + ) + } else { + markAsUnreadIfNeeded( + shouldMarkAsUnread: shouldMarkAsUnread, + targetPeerID: targetPeerID, + key: actualSenderNoiseKey, + isRecentMessage: isRecentMessage, + senderNickname: senderNickname, + messageContent: messageContent + ) + } + + viewModel.objectWillChange.send() + } + + func handlePrivateMessage(_ message: BitchatMessage) { + SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) + let senderPeerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) + + guard let peerID = senderPeerID else { + SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) + return + } + + if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { + handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) + return + } + + migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) + + if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + let stableKeyHex = PeerID(hexData: peer.noisePublicKey) + if stableKeyHex != peerID, + let nostrMessages = viewModel.privateChats[stableKeyHex], + !nostrMessages.isEmpty { + if viewModel.privateChats[peerID] == nil { + viewModel.privateChats[peerID] = [] + } + + let existingMessageIds = Set(viewModel.privateChats[peerID]?.map { $0.id } ?? []) + for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) { + viewModel.privateChats[peerID]?.append(nostrMessage) + } + + viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + viewModel.privateChats.removeValue(forKey: stableKeyHex) + + SecureLogger.info( + "📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", + category: .session + ) + } + } + + if isDuplicateMessage(message.id, targetPeerID: peerID) { + return + } + + addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) + let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) + + let isViewing = viewModel.selectedPrivateChatPeer == peerID + if isViewing { + let receipt = ReadReceipt( + originalMessageID: message.id, + readerID: viewModel.meshService.myPeerID, + readerNickname: viewModel.nickname + ) + viewModel.meshService.sendReadReceipt(receipt, to: peerID) + viewModel.sentReadReceipts.insert(message.id) + } else { + viewModel.unreadPrivateMessages.insert(peerID) + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: peerID + ) + } + + viewModel.objectWillChange.send() + } + + func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { + if viewModel.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + return true + } + for (_, messages) in viewModel.privateChats where messages.contains(where: { $0.id == messageId }) { + return true + } + return false + } + + func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { + if viewModel.privateChats[targetPeerID] == nil { + viewModel.privateChats[targetPeerID] = [] + } + if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { + viewModel.privateChats[targetPeerID]?[idx] = message + } else { + viewModel.privateChats[targetPeerID]?.append(message) + } + viewModel.privateChatManager.sanitizeChat(for: targetPeerID) + } + + func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { + guard let key, + let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + ephemeralPeerID != targetPeerID + else { + return + } + + if viewModel.privateChats[ephemeralPeerID] == nil { + viewModel.privateChats[ephemeralPeerID] = [] + } + if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { + viewModel.privateChats[ephemeralPeerID]?[idx] = message + } else { + viewModel.privateChats[ephemeralPeerID]?.append(message) + } + viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID) + } + + func handleViewingThisChat( + _ message: BitchatMessage, + targetPeerID: PeerID, + key: Data?, + senderPubkey: String + ) { + viewModel.unreadPrivateMessages.remove(targetPeerID) + if let key, + let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { + viewModel.unreadPrivateMessages.remove(ephemeralPeerID) + } + guard !viewModel.sentReadReceipts.contains(message.id) else { return } + + if let key { + let receipt = ReadReceipt( + originalMessageID: message.id, + readerID: viewModel.meshService.myPeerID, + readerNickname: viewModel.nickname + ) + SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) + viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) + viewModel.sentReadReceipts.insert(message.id) + } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { + let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) + transport.senderPeerID = viewModel.meshService.myPeerID + transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) + viewModel.sentReadReceipts.insert(message.id) + SecureLogger.debug( + "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", + category: .session + ) + } + } + + func markAsUnreadIfNeeded( + shouldMarkAsUnread: Bool, + targetPeerID: PeerID, + key: Data?, + isRecentMessage: Bool, + senderNickname: String, + messageContent: String + ) { + guard shouldMarkAsUnread else { return } + + viewModel.unreadPrivateMessages.insert(targetPeerID) + if let key, + let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + ephemeralPeerID != targetPeerID { + viewModel.unreadPrivateMessages.insert(ephemeralPeerID) + } + if isRecentMessage { + NotificationService.shared.sendPrivateMessageNotification( + from: senderNickname, + message: messageContent, + peerID: targetPeerID + ) + } + } + + func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { + let isFavorite = content.hasPrefix("[FAVORITED]") + let parts = content.split(separator: ":") + + var nostrPubkey: String? + if parts.count > 1 { + nostrPubkey = String(parts[1]) + SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) + } + + let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + guard let finalNoiseKey = noiseKey else { + SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) + return + } + + let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: finalNoiseKey, + favorited: isFavorite, + peerNickname: senderNickname, + peerNostrPublicKey: nostrPubkey + ) + + if isFavorite && nostrPubkey != nil { + SecureLogger.info( + "💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", + category: .session + ) + } + + if prior != isFavorite { + let action = isFavorite ? "favorited" : "unfavorited" + viewModel.addMeshOnlySystemMessage("\(senderNickname) \(action) you") + } + } + + func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { + let isActionMessage = message.content.hasPrefix("* ") + && message.content.hasSuffix(" *") + && (message.content.contains("🫂") + || message.content.contains("🐟") + || message.content.contains("took a screenshot")) + + guard isActionMessage else { return message } + + return BitchatMessage( + id: message.id, + sender: "system", + content: String(message.content.dropFirst(2).dropLast(2)), + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + } + + func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { + let currentFingerprint = viewModel.getFingerprint(for: peerID) + + if viewModel.privateChats[peerID] == nil || viewModel.privateChats[peerID]?.isEmpty == true { + var migratedMessages: [BitchatMessage] = [] + var oldPeerIDsToRemove: [PeerID] = [] + let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) + + for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID { + let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID] + let recentMessages = messages.filter { $0.timestamp > cutoffTime } + guard !recentMessages.isEmpty else { continue } + + if let currentFp = currentFingerprint, + let oldFp = oldFingerprint, + currentFp == oldFp { + migratedMessages.append(contentsOf: recentMessages) + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } else { + SecureLogger.info( + "📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", + category: .session + ) + } + + SecureLogger.info( + "📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", + category: .session + ) + } else if currentFingerprint == nil || oldFingerprint == nil { + let isRelevantChat = recentMessages.contains { msg in + (msg.sender == senderNickname && msg.sender != viewModel.nickname) + || (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname) + } + + if isRelevantChat { + migratedMessages.append(contentsOf: recentMessages) + if recentMessages.count == messages.count { + oldPeerIDsToRemove.append(oldPeerID) + } + + SecureLogger.warning( + "📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", + category: .session + ) + } + } + } + + if !oldPeerIDsToRemove.isEmpty { + let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 } + + for oldID in oldPeerIDsToRemove { + viewModel.privateChats.removeValue(forKey: oldID) + viewModel.unreadPrivateMessages.remove(oldID) + viewModel.peerIdentityStore.setFingerprint(nil, for: oldID) + } + + if needsSelectedUpdate { + viewModel.selectedPrivateChatPeer = peerID + } + } + + if !migratedMessages.isEmpty { + if viewModel.privateChats[peerID] == nil { + viewModel.privateChats[peerID] = [] + } + viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages) + viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + viewModel.privateChatManager.sanitizeChat(for: peerID) + viewModel.objectWillChange.send() + } + } + } + + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + var noiseKey: Data? + + if let hexKey = Data(hexString: peerID.id) { + noiseKey = hexKey + } else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + noiseKey = peer.noisePublicKey + } + + if viewModel.meshService.isPeerConnected(peerID) { + viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) + } else if let key = noiseKey { + viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) + } else { + SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) + } + } + + func isMessageBlocked(_ message: BitchatMessage) -> Bool { + if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) { + if viewModel.isPeerBlocked(peerID) { return true } + if peerID.isGeoChat || peerID.isGeoDM, + let full = viewModel.nostrKeyMapping[peerID]?.lowercased(), + viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) { + return true + } + } + return false + } +} diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift new file mode 100644 index 00000000..fcd1d448 --- /dev/null +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -0,0 +1,464 @@ +import BitFoundation +import BitLogger +import CoreBluetooth +import Foundation +import SwiftUI +#if os(iOS) +import UIKit +#endif + +@MainActor +final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func visibleGeohashPeople() -> [GeoPerson] { + viewModel.participantTracker.getVisiblePeople() + } + + func getVisibleGeoParticipants() -> [CommandGeoParticipant] { + visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) } + } + + func geohashParticipantCount(for geohash: String) -> Int { + viewModel.participantTracker.participantCount(for: geohash) + } + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + displayNameForNostrPubkey(pubkeyHex) + } + + func isBlocked(_ pubkeyHexLowercased: String) -> Bool { + viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + } + + func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { + viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + } + + func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + let hex = pubkeyHexLowercased.lowercased() + viewModel.identityManager.setNostrBlocked(hex, isBlocked: true) + viewModel.participantTracker.removeParticipant(pubkeyHex: hex) + + if let gh = viewModel.currentGeohash { + let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in + guard let senderPeerID = message.senderPeerID, + senderPeerID.isGeoDM || senderPeerID.isGeoChat else { + return false + } + if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() { + return full == hex + } + return false + } + viewModel.timelineStore.removeMessages(in: gh, where: predicate) + synchronizePublicConversationStore(forGeohash: gh) + if case .location = viewModel.activeChannel { + viewModel.messages.removeAll(where: predicate) + } + } + + let conversationPeerID = PeerID(nostr_: hex) + if viewModel.privateChats[conversationPeerID] != nil { + var privateChats = viewModel.privateChats + privateChats.removeValue(forKey: conversationPeerID) + viewModel.privateChats = privateChats + + var unread = viewModel.unreadPrivateMessages + unread.remove(conversationPeerID) + viewModel.unreadPrivateMessages = unread + } + + for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex { + viewModel.nostrKeyMapping.removeValue(forKey: key) + } + + addSystemMessage( + String( + format: String( + localized: "system.geohash.blocked", + comment: "System message shown when a user is blocked in geohash chats" + ), + locale: .current, + displayName + ) + ) + } + + func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) + addSystemMessage( + String( + format: String( + localized: "system.geohash.unblocked", + comment: "System message shown when a user is unblocked in geohash chats" + ), + locale: .current, + displayName + ) + ) + } + + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { + let suffix = String(pubkeyHex.suffix(4)) + if let geohash = viewModel.currentGeohash, + let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash), + myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { + return viewModel.nickname + "#" + suffix + } + if let nick = viewModel.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { + return nick + "#" + suffix + } + return "anon#\(suffix)" + } + + func currentPublicSender() -> (name: String, peerID: PeerID) { + var displaySender = viewModel.nickname + var senderPeerID = viewModel.meshService.myPeerID + if case .location(let channel) = viewModel.activeChannel, + let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + let suffix = String(identity.publicKeyHex.suffix(4)) + displaySender = viewModel.nickname + "#" + suffix + senderPeerID = PeerID(nostr: identity.publicKeyHex) + } + return (displaySender, senderPeerID) + } + + func removeMessage(withID messageID: String, cleanupFile: Bool = false) { + var removedMessage: BitchatMessage? + + if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) { + removedMessage = viewModel.messages.remove(at: index) + } + + if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) { + removedMessage = removedMessage ?? storeRemoved + synchronizeAllPublicConversationStores() + } + + var chats = viewModel.privateChats + for (peerID, items) in chats { + let filtered = items.filter { $0.id != messageID } + if filtered.count != items.count { + if filtered.isEmpty { + chats.removeValue(forKey: peerID) + } else { + chats[peerID] = filtered + } + if removedMessage == nil { + removedMessage = items.first(where: { $0.id == messageID }) + } + } + } + viewModel.privateChats = chats + + if cleanupFile, let removedMessage { + viewModel.cleanupLocalFile(forMessage: removedMessage) + } + + viewModel.objectWillChange.send() + } + + func initializeConversationStore() { + viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) + synchronizePublicConversationStore(for: viewModel.activeChannel) + viewModel.synchronizePrivateConversationStore() + viewModel.synchronizeConversationSelectionStore() + } + + func synchronizePublicConversationStore(for channel: ChannelID) { + let publicMessages = viewModel.timelineStore.messages(for: channel) + viewModel.conversationStore.replaceMessages(publicMessages, for: channel) + if channel == viewModel.activeChannel { + viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) + } + } + + func synchronizePublicConversationStore(forGeohash geohash: String) { + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let publicMessages = viewModel.timelineStore.messages(for: channel) + viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased())) + } + + func synchronizeAllPublicConversationStores() { + synchronizePublicConversationStore(for: .mesh) + for geohash in viewModel.timelineStore.geohashKeys() { + synchronizePublicConversationStore(forGeohash: geohash) + } + } + + func refreshVisibleMessages(from channel: ChannelID? = nil) { + let target = channel ?? viewModel.activeChannel + viewModel.messages = viewModel.timelineStore.messages(for: target) + viewModel.conversationStore.replaceMessages(viewModel.messages, for: target) + if target == viewModel.activeChannel { + viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) + } + } + + func clearCurrentPublicTimeline() { + viewModel.messages.removeAll() + viewModel.timelineStore.clear(channel: viewModel.activeChannel) + + Task.detached(priority: .utility) { + do { + let base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let filesDir = base.appendingPathComponent("files", isDirectory: true) + let outgoingDirs = [ + filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), + filesDir.appendingPathComponent("images/outgoing", isDirectory: true), + filesDir.appendingPathComponent("files/outgoing", isDirectory: true) + ] + + for dir in outgoingDirs { + if FileManager.default.fileExists(atPath: dir.path) { + try? FileManager.default.removeItem(at: dir) + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true, + attributes: nil + ) + } + } + } catch { + SecureLogger.error("Failed to clear media files: \(error)", category: .session) + } + } + } + + func addSystemMessage(_ content: String, timestamp: Date = Date()) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: timestamp, + isRelay: false + ) + viewModel.messages.append(systemMessage) + } + + func addMeshOnlySystemMessage(_ content: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + viewModel.timelineStore.append(systemMessage, to: .mesh) + synchronizePublicConversationStore(for: .mesh) + refreshVisibleMessages() + viewModel.trimMessagesIfNeeded() + viewModel.objectWillChange.send() + } + + func addPublicSystemMessage(_ content: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false + ) + viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel) + refreshVisibleMessages(from: viewModel.activeChannel) + let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content) + viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) + viewModel.trimMessagesIfNeeded() + viewModel.objectWillChange.send() + } + + func addGeohashOnlySystemMessage(_ content: String) { + if case .location = viewModel.activeChannel { + addPublicSystemMessage(content) + } else { + viewModel.timelineStore.queueGeohashSystemMessage(content) + } + } + + func sendPublicRaw(_ content: String) { + if case .location(let channel) = viewModel.activeChannel { + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + do { + let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: channel.geohash, + senderIdentity: identity, + nickname: viewModel.nickname, + teleported: viewModel.locationManager.teleported + ) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) + if targetRelays.isEmpty { + NostrRelayManager.shared.sendEvent(event) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + } catch { + SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session) + } + } + return + } + + viewModel.meshService.sendMessage( + content, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date() + ) + } + + func handlePublicMessage(_ message: BitchatMessage) { + let finalMessage = viewModel.processActionMessage(message) + if viewModel.isMessageBlocked(finalMessage) { return } + + let isGeo = finalMessage.senderPeerID?.isGeoChat == true + let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil + if shouldRateLimit { + let senderKey = normalizedSenderKey(for: finalMessage) + let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content) + if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { + return + } + } + + if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } + + if !isGeo && finalMessage.sender != "system" { + viewModel.timelineStore.append(finalMessage, to: .mesh) + synchronizePublicConversationStore(for: .mesh) + } + + if isGeo && finalMessage.sender != "system", + let geohash = viewModel.currentGeohash, + viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) { + synchronizePublicConversationStore(forGeohash: geohash) + } + + let isSystem = finalMessage.sender == "system" + let channelMatches: Bool = { + switch viewModel.activeChannel { + case .mesh: return !isGeo || isSystem + case .location: return isGeo || isSystem + } + }() + + guard channelMatches else { return } + + if !finalMessage.content.trimmed.isEmpty, + !viewModel.messages.contains(where: { $0.id == finalMessage.id }) { + viewModel.publicMessagePipeline.enqueue(finalMessage) + } + } + + func checkForMentions(_ message: BitchatMessage) { + var myTokens: Set = [viewModel.nickname] + let meshPeers = viewModel.meshService.getPeerNicknames() + let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") } + if !collisions.isEmpty { + let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4)) + myTokens = [viewModel.nickname + suffix] + } + let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false + + if isMentioned && message.sender != viewModel.nickname { + SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) + NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + } + } + + func sendHapticFeedback(for message: BitchatMessage) { + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + + var tokens: [String] = [viewModel.nickname] + switch viewModel.activeChannel { + case .location(let channel): + if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))) + } + case .mesh: + break + } + + let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") + let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") + let isHugForMe = message.content.contains("🫂") && hugsMe + let isSlapForMe = message.content.contains("🐟") && slapsMe + + if isHugForMe && message.sender != viewModel.nickname { + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.prepare() + + for i in 0..<8 { + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds + ) { + impactFeedback.impactOccurred() + } + } + } else if isSlapForMe && message.sender != viewModel.nickname { + let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) + impactFeedback.prepare() + impactFeedback.impactOccurred() + } + #endif + } + + func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { + viewModel.messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { + viewModel.messages = messages + } + + func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { + viewModel.deduplicationService.normalizedContentKey(content) + } + + func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { + viewModel.deduplicationService.contentTimestamp(forKey: key) + } + + func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { + viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) + } + + func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { + viewModel.trimMessagesIfNeeded() + } + + func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { + _ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme) + } + + func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { + viewModel.isBatchingPublic = isBatching + } +} + +private extension ChatPublicConversationCoordinator { + func normalizedSenderKey(for message: BitchatMessage) -> String { + if let senderPeerID = message.senderPeerID { + if senderPeerID.isGeoChat || senderPeerID.isGeoDM { + let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased() + return "nostr:" + full + } else if senderPeerID.id.count == 16, + let full = viewModel.cachedStablePeerID(for: senderPeerID)?.id.lowercased() { + return "noise:" + full + } else { + return "mesh:" + senderPeerID.id.lowercased() + } + } + return "name:" + message.sender.lowercased() + } +} diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift new file mode 100644 index 00000000..028942b2 --- /dev/null +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -0,0 +1,280 @@ +import BitFoundation +import BitLogger +import Foundation + +final class ChatTransportEventCoordinator { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func didReceiveMessage(_ message: BitchatMessage) { + runOnMain { viewModel in + guard !viewModel.isMessageBlocked(message) else { return } + guard !message.content.trimmed.isEmpty || message.isPrivate else { return } + + if message.isPrivate { + viewModel.handlePrivateMessage(message) + } else { + viewModel.handlePublicMessage(message) + } + + viewModel.checkForMentions(message) + viewModel.sendHapticFeedback(for: message) + } + } + + func didReceivePublicMessage( + from peerID: PeerID, + nickname: String, + content: String, + timestamp: Date, + messageID: String? + ) { + runOnMain { viewModel in + let normalized = content.trimmed + let mentions = viewModel.parseMentions(from: normalized) + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: normalized, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: peerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + viewModel.handlePublicMessage(message) + viewModel.checkForMentions(message) + viewModel.sendHapticFeedback(for: message) + } + } + + func didReceiveNoisePayload( + from peerID: PeerID, + type: NoisePayloadType, + payload: Data, + timestamp: Date + ) { + runOnMain { [self] viewModel in + handleNoisePayload( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp, + in: viewModel + ) + } + } + + func didConnectToPeer(_ peerID: PeerID) { + SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) + + runOnMain { viewModel in + viewModel.isConnected = true + viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + viewModel.objectWillChange.send() + + if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + let stablePeerID = PeerID(hexData: peer.noisePublicKey) + viewModel.cacheStablePeerID(stablePeerID, for: peerID) + } + + viewModel.messageRouter.flushOutbox(for: peerID) + } + } + + func didDisconnectFromPeer(_ peerID: PeerID) { + SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) + + runOnMain { viewModel in + viewModel.identityManager.removeEphemeralSession(peerID: peerID) + + var stablePeerID = viewModel.cachedStablePeerID(for: peerID) + if stablePeerID == nil, + let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let derivedPeerID = PeerID(hexData: key) + viewModel.cacheStablePeerID(derivedPeerID, for: peerID) + stablePeerID = derivedPeerID + } + + if let currentPeerID = viewModel.selectedPrivateChatPeer, + currentPeerID == peerID, + let stablePeerID { + self.migrateSelectedConversationIfNeeded( + from: peerID, + to: stablePeerID, + in: viewModel + ) + } + + if let messages = viewModel.privateChats[peerID] { + for message in messages where message.senderPeerID == peerID { + viewModel.sentReadReceipts.remove(message.id) + } + } + + viewModel.objectWillChange.send() + } + } +} + +private extension ChatTransportEventCoordinator { + func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) { + Task { @MainActor [weak viewModel = self.viewModel] in + guard let viewModel else { return } + action(viewModel) + } + } + + @MainActor + func migrateSelectedConversationIfNeeded( + from shortPeerID: PeerID, + to stablePeerID: PeerID, + in viewModel: ChatViewModel + ) { + if let messages = viewModel.privateChats[shortPeerID] { + if viewModel.privateChats[stablePeerID] == nil { + viewModel.privateChats[stablePeerID] = [] + } + + let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? []) + for message in messages where !existingIDs.contains(message.id) { + let migrated = BitchatMessage( + id: message.id, + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID + ? viewModel.meshService.myPeerID + : stablePeerID, + mentions: message.mentions, + deliveryStatus: message.deliveryStatus + ) + viewModel.privateChats[stablePeerID]?.append(migrated) + } + + viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } + viewModel.privateChats.removeValue(forKey: shortPeerID) + } + + if viewModel.unreadPrivateMessages.contains(shortPeerID) { + viewModel.unreadPrivateMessages.remove(shortPeerID) + viewModel.unreadPrivateMessages.insert(stablePeerID) + } + + viewModel.selectedPrivateChatPeer = stablePeerID + } + + @MainActor + func handleNoisePayload( + from peerID: PeerID, + type: NoisePayloadType, + payload: Data, + timestamp: Date, + in viewModel: ChatViewModel + ) { + switch type { + case .privateMessage: + guard let packet = PrivateMessagePacket.decode(from: payload) else { return } + + guard !viewModel.isPeerBlocked(peerID) else { + SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) + return + } + + let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" + let mentions = viewModel.parseMentions(from: packet.content) + let message = BitchatMessage( + id: packet.messageID, + sender: senderName, + content: packet.content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID, + mentions: mentions.isEmpty ? nil : mentions + ) + viewModel.handlePrivateMessage(message) + viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID) + + case .delivered: + guard let messageID = String(data: payload, encoding: .utf8), + let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname, + let (foundPeerID, index) = findMessageIndex( + for: messageID, + peerID: peerID, + in: viewModel + ) else { return } + + if case .read = viewModel.privateChats[foundPeerID]?[index].deliveryStatus { return } + + viewModel.privateChats[foundPeerID]?[index].deliveryStatus = .delivered(to: name, at: Date()) + viewModel.objectWillChange.send() + + case .readReceipt: + guard let messageID = String(data: payload, encoding: .utf8), + let name = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname, + let (foundPeerID, index) = findMessageIndex( + for: messageID, + peerID: peerID, + in: viewModel + ), + let messages = viewModel.privateChats[foundPeerID], + index < messages.count else { return } + + messages[index].deliveryStatus = .read(by: name, at: Date()) + viewModel.privateChats[foundPeerID] = messages + viewModel.privateChatManager.objectWillChange.send() + viewModel.objectWillChange.send() + + case .verifyChallenge: + viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + + case .verifyResponse: + viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) + } + } + + @MainActor + func findMessageIndex( + for messageID: String, + peerID: PeerID, + in viewModel: ChatViewModel + ) -> (peerID: PeerID, index: Int)? { + if let messages = viewModel.privateChats[peerID], + let index = messages.firstIndex(where: { $0.id == messageID }) { + return (peerID, index) + } + + if peerID.bare.count == 16, + let peer = viewModel.unifiedPeerService.getPeer(by: peerID), + !peer.noisePublicKey.isEmpty { + let longID = PeerID(hexData: peer.noisePublicKey) + if let messages = viewModel.privateChats[longID], + let index = messages.firstIndex(where: { $0.id == messageID }) { + return (longID, index) + } + } + + if peerID.bare.count == 64 { + let shortID = peerID.toShort() + if let messages = viewModel.privateChats[shortID], + let index = messages.firstIndex(where: { $0.id == messageID }) { + return (shortID, index) + } + } + + return nil + } +} diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift new file mode 100644 index 00000000..af95b7b2 --- /dev/null +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -0,0 +1,251 @@ +import BitFoundation +import BitLogger +import Foundation +import Security + +@MainActor +final class ChatVerificationCoordinator { + struct PendingVerification { + let noiseKeyHex: String + let signKeyHex: String + let nonceA: Data + let startedAt: Date + var sent: Bool + } + + private unowned let viewModel: ChatViewModel + private var pendingQRVerifications: [PeerID: PendingVerification] = [:] + private var lastVerifyNonceByPeer: [PeerID: Data] = [:] + private var lastInboundVerifyChallengeAt: [String: Date] = [:] + private var lastMutualToastAt: [String: Date] = [:] + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + func verifyFingerprint(for peerID: PeerID) { + guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + + viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) + viewModel.saveIdentityState() + viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) + viewModel.updateEncryptionStatus(for: peerID) + } + + func unverifyFingerprint(for peerID: PeerID) { + guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false) + viewModel.saveIdentityState() + viewModel.peerIdentityStore.setVerified(fingerprint, verified: false) + viewModel.updateEncryptionStatus(for: peerID) + } + + func loadVerifiedFingerprints() { + viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints()) + let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) + .map { $0.prefix(8) } + .joined(separator: ", ") + SecureLogger.info("🔐 Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security) + + let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected } + for favorite in offlineFavorites { + let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID) + let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false + let shortFingerprint = fingerprint?.prefix(8) ?? "nil" + SecureLogger.info( + "⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)", + category: .security + ) + } + + viewModel.invalidateEncryptionCache() + viewModel.objectWillChange.send() + } + + func setupNoiseCallbacks() { + let noiseService = viewModel.meshService.getNoiseService() + + noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in + DispatchQueue.main.async { + guard let self else { return } + + SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) + + if self.viewModel.peerIdentityStore.isVerified(fingerprint) { + self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID) + } else { + self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID) + } + + self.viewModel.invalidateEncryptionCache(for: peerID) + + if self.viewModel.cachedStablePeerID(for: peerID) == nil, + let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let stablePeerID = PeerID(hexData: keyData) + self.viewModel.cacheStablePeerID(stablePeerID, for: peerID) + SecureLogger.debug( + "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))…", + category: .session + ) + } + + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { + self.viewModel.meshService.sendVerifyChallenge( + to: peerID, + noiseKeyHex: pending.noiseKeyHex, + nonceA: pending.nonceA + ) + pending.sent = true + self.pendingQRVerifications[peerID] = pending + SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) + } + } + } + + noiseService.onHandshakeRequired = { [weak self] peerID in + DispatchQueue.main.async { + guard let self else { return } + self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) + self.viewModel.invalidateEncryptionCache(for: peerID) + } + } + } + + func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { + let targetNoise = qr.noiseKeyHex.lowercased() + guard let peer = viewModel.unifiedPeerService.peers.first(where: { + $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise + }) else { + return false + } + + let peerID = peer.peerID + if pendingQRVerifications[peerID] != nil { + return true + } + + 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 + + let noise = viewModel.meshService.getNoiseService() + if noise.hasEstablishedSession(with: peerID) { + viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + pending.sent = true + pendingQRVerifications[peerID] = pending + } else { + viewModel.meshService.triggerHandshake(with: peerID) + } + + return true + } + + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { + guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return } + + let myNoiseHex = viewModel.meshService + .getNoiseService() + .getStaticPublicKeyData() + .hexEncodedString() + .lowercased() + guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return } + guard lastVerifyNonceByPeer[peerID] != challenge.nonceA else { return } + + lastVerifyNonceByPeer[peerID] = challenge.nonceA + + if let fingerprint = viewModel.getFingerprint(for: peerID) { + lastInboundVerifyChallengeAt[fingerprint] = Date() + + if viewModel.peerIdentityStore.isVerified(fingerprint) { + maybeSendMutualVerificationNotification( + fingerprint: fingerprint, + peerID: peerID, + title: "Mutual verification", + bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname + ?? viewModel.resolveNickname(for: peerID), + notificationPrefix: "verify-mutual" + ) + } + } + + viewModel.meshService.sendVerifyResponse( + to: peerID, + noiseKeyHex: challenge.noiseKeyHex, + nonceA: challenge.nonceA + ) + } + + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { + guard let response = VerificationService.shared.parseVerifyResponse(payload), + let pending = pendingQRVerifications[peerID], + response.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), + response.nonceA == pending.nonceA else { return } + + let isValid = VerificationService.shared.verifyResponseSignature( + noiseKeyHex: response.noiseKeyHex, + nonceA: response.nonceA, + signature: response.signature, + signerPublicKeyHex: pending.signKeyHex + ) + guard isValid else { return } + + pendingQRVerifications.removeValue(forKey: peerID) + + guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + + let shortFingerprint = fingerprint.prefix(8) + SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security) + viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) + viewModel.saveIdentityState() + viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) + + let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname + ?? viewModel.resolveNickname(for: peerID) + NotificationService.shared.sendLocalNotification( + title: "Verified", + body: "You verified \(peerName)", + identifier: "verify-success-\(peerID)-\(UUID().uuidString)" + ) + + if let challengeTime = lastInboundVerifyChallengeAt[fingerprint], + Date().timeIntervalSince(challengeTime) < 600 { + maybeSendMutualVerificationNotification( + fingerprint: fingerprint, + peerID: peerID, + title: "Mutual verification", + bodyName: peerName, + notificationPrefix: "verify-mutual" + ) + } + + viewModel.updateEncryptionStatus(for: peerID) + } +} + +private extension ChatVerificationCoordinator { + func maybeSendMutualVerificationNotification( + fingerprint: String, + peerID: PeerID, + title: String, + bodyName: String, + notificationPrefix: String + ) { + let now = Date() + let lastToast = lastMutualToastAt[fingerprint] ?? .distantPast + guard now.timeIntervalSince(lastToast) > 60 else { return } + + lastMutualToastAt[fingerprint] = now + NotificationService.shared.sendLocalNotification( + title: title, + body: "You and \(bodyName) verified each other", + identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)" + ) + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a6f5b23c..77e418f2 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -110,39 +110,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } } - private var publicRateLimiter = MessageRateLimiter( + var publicRateLimiter = MessageRateLimiter( senderCapacity: TransportConfig.uiSenderRateBucketCapacity, senderRefillPerSec: TransportConfig.uiSenderRateBucketRefillPerSec, contentCapacity: TransportConfig.uiContentRateBucketCapacity, contentRefillPerSec: TransportConfig.uiContentRateBucketRefillPerSec ) - @MainActor - private func normalizedSenderKey(for message: BitchatMessage) -> String { - if let spid = message.senderPeerID { - if spid.isGeoChat || spid.isGeoDM { - let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased() - return "nostr:" + full - } else if spid.id.count == 16, let full = getNoiseKeyForShortID(spid)?.id.lowercased() { - return "noise:" + full - } else { - return "mesh:" + spid.id.lowercased() - } - } - return "name:" + message.sender.lowercased() - } - // MARK: - Published Properties @Published var messages: [BitchatMessage] = [] @Published var currentColorScheme: ColorScheme = .light private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed @Published var isConnected = false - private var recentlySeenPeers: Set = [] - private var lastNetworkNotificationTime = Date.distantPast - private var networkResetTimer: Timer? = nil - private var networkEmptyTimer: Timer? = nil - private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set; whitespace-only becomes "" @@ -166,6 +146,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access + private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self) + private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self) + private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self) + private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) + private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) + lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self) + lazy var deliveryCoordinator = ChatDeliveryCoordinator(viewModel: self) + lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) + lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(viewModel: self) + lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(viewModel: self) + lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self) + lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) + lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) // Computed properties for compatibility @MainActor @@ -173,7 +166,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @Published var allPeers: [BitchatPeer] = [] var privateChats: [PeerID: [BitchatMessage]] { get { privateChatManager.privateChats } - set { privateChatManager.privateChats = newValue } + set { + privateChatManager.privateChats = newValue + synchronizePrivateConversationStore() + } } var selectedPrivateChatPeer: PeerID? { get { privateChatManager.selectedPeer } @@ -183,11 +179,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } else { privateChatManager.endChat() } + synchronizePrivateConversationStore() + synchronizeConversationSelectionStore() } } var unreadPrivateMessages: Set { get { privateChatManager.unreadMessages } - set { privateChatManager.unreadMessages = newValue } + set { + privateChatManager.unreadMessages = newValue + synchronizePrivateConversationStore() + } } /// Check if there are any unread messages (including from temporary Nostr peer IDs) @@ -199,38 +200,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Prefers the most recently active unread conversation, otherwise the most recent PM. @MainActor func openMostRelevantPrivateChat() { - // Pick most recent unread by last message timestamp - let unreadSorted = unreadPrivateMessages - .map { ($0, privateChats[$0]?.last?.timestamp ?? Date.distantPast) } - .sorted { $0.1 > $1.1 } - if let target = unreadSorted.first?.0 { - startPrivateChat(with: target) - return - } - // Otherwise pick most recent private chat overall - let recent = privateChats - .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } - .sorted { $0.ts > $1.ts } - if let target = recent.first?.id { - startPrivateChat(with: target) - } + peerIdentityCoordinator.openMostRelevantPrivateChat() } // - var peerIDToPublicKeyFingerprint: [PeerID: String] = [:] - private var selectedPrivateChatFingerprint: String? = nil - // Map stable short peer IDs (16-hex) to full Noise public key hex (64-hex) for session continuity - private var shortIDToNoiseKey: [PeerID: PeerID] = [:] + var peerIDToPublicKeyFingerprint: [PeerID: String] { + get { peerIdentityStore.peerFingerprintsByPeerID } + set { peerIdentityStore.replaceFingerprintMappings(newValue) } + } + var selectedPrivateChatFingerprint: String? { + get { peerIdentityStore.selectedPrivateChatFingerprint } + set { peerIdentityStore.setSelectedPrivateChatFingerprint(newValue) } + } // Resolve full Noise key for a peer's short ID (used by UI header rendering) @MainActor private func getNoiseKeyForShortID(_ shortPeerID: PeerID) -> PeerID? { - if let mapped = shortIDToNoiseKey[shortPeerID] { return mapped } + if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped } // Fallback: derive from active Noise session if available if shortPeerID.id.count == 16, let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) { let stable = PeerID(hexData: key) - shortIDToNoiseKey[shortPeerID] = stable + peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID) return stable } return nil @@ -245,12 +236,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return match.peerID } // Also search cache mapping - if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) { - return pair.key + if let shortPeerID = peerIdentityStore.shortPeerID(forStablePeerID: fullNoiseKeyHex) { + return shortPeerID } return fullNoiseKeyHex } - private var peerIndex: [PeerID: BitchatPeer] = [:] + + @MainActor + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { + peerIdentityStore.setStablePeerID(stablePeerID, forShortID: shortPeerID) + } + + @MainActor + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { + peerIdentityStore.stablePeerID(forShortID: shortPeerID) + } + + var hasTrackedPrivateChatSelection: Bool { + selectedPrivateChatFingerprint != nil + } + + var peerIndex: [PeerID: BitchatPeer] = [:] // MARK: - Autocomplete Properties @@ -259,26 +265,44 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @Published var autocompleteRange: NSRange? = nil @Published var selectedAutocompleteIndex: Int = 0 - // Temporary property to fix compilation - @Published var showPasswordPrompt = false - // MARK: - Services and Storage let meshService: Transport let idBridge: NostrIdentityBridge let identityManager: SecureIdentityStateManagerProtocol + let conversationStore: ConversationStore + let identityResolver: IdentityResolver + let peerIdentityStore: PeerIdentityStore + let locationPresenceStore: LocationPresenceStore + let locationManager: LocationChannelManager var nostrRelayManager: NostrRelayManager? private let userDefaults = UserDefaults.standard let keychain: KeychainManagerProtocol private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) - @Published var activeChannel: ChannelID = .mesh + var activeChannel: ChannelID { + get { conversationStore.activeChannel } + set { + guard conversationStore.activeChannel != newValue else { return } + publicMessagePipeline.updateActiveChannel(newValue) + conversationStore.setActiveChannel(newValue) + synchronizePublicConversationStore(for: newValue) + synchronizeConversationSelectionStore() + objectWillChange.send() + } + } var geoSubscriptionID: String? = nil var geoDmSubscriptionID: String? = nil - var currentGeohash: String? = nil + var currentGeohash: String? { + get { locationPresenceStore.currentGeohash } + set { locationPresenceStore.setCurrentGeohash(newValue) } + } var cachedGeohashIdentity: (geohash: String, identity: NostrIdentity)? = nil // Cache current geohash identity - var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname + var geoNicknames: [String: String] { + get { locationPresenceStore.geoNicknames } + set { locationPresenceStore.replaceGeoNicknames(newValue) } + } // pubkeyHex(lowercased) -> nickname // Show Tor status once per app launch var torStatusAnnounced = false // Track whether a Tor restart is pending so we only announce @@ -290,9 +314,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Caches - // Caches for expensive computations - private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:] - // MARK: - Social Features (Delegated to PeerStateManager) @MainActor @@ -303,30 +324,47 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Encryption and Security // Noise Protocol encryption status - @Published var peerEncryptionStatus: [PeerID: EncryptionStatus] = [:] - @Published var verifiedFingerprints: Set = [] // Set of verified fingerprints - @Published var showingFingerprintFor: PeerID? = nil // Currently showing fingerprint sheet for peer + var peerEncryptionStatus: [PeerID: EncryptionStatus] { + get { peerIdentityStore.encryptionStatuses } + set { peerIdentityStore.replaceEncryptionStatuses(newValue) } + } + var verifiedFingerprints: Set { + get { peerIdentityStore.verifiedFingerprints } + set { peerIdentityStore.setVerifiedFingerprints(newValue) } + } // Set of verified fingerprints // Bluetooth state management @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown - // Presentation state for privacy gating - @Published var isLocationChannelsSheetPresented: Bool = false - @Published var isAppInfoPresented: Bool = false - @Published var showScreenshotPrivacyWarning: Bool = false - var timelineStore = PublicTimelineStore( meshCap: TransportConfig.meshTimelineCap, geohashCap: TransportConfig.geoTimelineCap ) + + private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated { + update(deliveryCoordinator) + } + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + update(self.deliveryCoordinator) + } + } // Channel activity tracking for background nudges var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time // Geohash participant tracker let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) - @Published var teleportedGeo: Set = [] // lowercased pubkey hex + var teleportedGeo: Set { + get { locationPresenceStore.teleportedGeo } + set { locationPresenceStore.replaceTeleportedGeo(newValue) } + } // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time @@ -334,30 +372,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Message Delivery Tracking - // Delivery tracking var cancellables = Set() - var transferIdToMessageIDs: [String: [String]] = [:] - var messageIDToTransferId: [String: String] = [:] - // MARK: - QR Verification (pending state) - private struct PendingVerification { - let noiseKeyHex: String - let signKeyHex: String - let nonceA: Data - let startedAt: Date - var sent: Bool + var transferIdToMessageIDs: [String: [String]] { + mediaTransferCoordinator.transferIdToMessageIDs + } + + var messageIDToTransferId: [String: String] { + mediaTransferCoordinator.messageIDToTransferId } - private var pendingQRVerifications: [PeerID: PendingVerification] = [:] - // Last handled challenge nonce per peer to avoid duplicate responses - private var lastVerifyNonceByPeer: [PeerID: Data] = [:] - // Track when we last received a verify challenge from a peer (fingerprint-keyed) - private var lastInboundVerifyChallengeAt: [String: Date] = [:] // key: fingerprint - // Throttle mutual verification toasts per fingerprint - private var lastMutualToastAt: [String: Date] = [:] // key: fingerprint // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline - @Published private(set) var isBatchingPublic: Bool = false + @Published var isBatchingPublic: Bool = false // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -375,14 +402,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } } - // Throttle verification response toasts per peer to avoid spam - var lastVerifyToastAt: [String: Date] = [:] - // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) var sentGeoDeliveryAcks: Set = [] // Track app startup phase to prevent marking old messages as unread - private var isStartupPhase = true + var isStartupPhase = true // Announce Tor initial readiness once per launch to avoid duplicates var torInitialReadyAnnounced: Bool = false @@ -395,13 +419,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv convenience init( keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, - identityManager: SecureIdentityStateManagerProtocol + identityManager: SecureIdentityStateManagerProtocol, + conversationStore: ConversationStore? = nil, + identityResolver: IdentityResolver? = nil, + peerIdentityStore: PeerIdentityStore? = nil, + locationPresenceStore: LocationPresenceStore? = nil, + locationManager: LocationChannelManager = .shared ) { + let conversationStore = conversationStore ?? ConversationStore() + let identityResolver = identityResolver ?? IdentityResolver() self.init( keychain: keychain, idBridge: idBridge, identityManager: identityManager, - transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) + transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), + conversationStore: conversationStore, + identityResolver: identityResolver, + peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), + locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), + locationManager: locationManager ) } @@ -412,325 +448,44 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - transport: Transport + transport: Transport, + conversationStore: ConversationStore? = nil, + identityResolver: IdentityResolver? = nil, + peerIdentityStore: PeerIdentityStore? = nil, + locationPresenceStore: LocationPresenceStore? = nil, + locationManager: LocationChannelManager = .shared ) { + let conversationStore = conversationStore ?? ConversationStore() + let identityResolver = identityResolver ?? IdentityResolver() + let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() + let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore() + let services = ChatViewModelServiceBundle( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + meshService: transport + ) + self.keychain = keychain self.idBridge = idBridge self.identityManager = identityManager + self.conversationStore = conversationStore + self.identityResolver = identityResolver + self.peerIdentityStore = peerIdentityStore + self.locationPresenceStore = locationPresenceStore + self.locationManager = locationManager self.meshService = transport - self.publicMessagePipeline = PublicMessagePipeline() - - // Load persisted read receipts - if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"), - let receipts = try? JSONDecoder().decode([String].self, from: data) { - self.sentReadReceipts = Set(receipts) - // Successfully loaded read receipts - } else { - // No persisted read receipts found - } - - // Initialize services - self.commandProcessor = CommandProcessor(identityManager: identityManager) - self.privateChatManager = PrivateChatManager(meshService: meshService) - self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager) - let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) - nostrTransport.senderPeerID = meshService.myPeerID - self.messageRouter = MessageRouter(transports: [meshService, nostrTransport]) - // Route receipts from PrivateChatManager through MessageRouter - self.privateChatManager.messageRouter = self.messageRouter - // Allow PrivateChatManager to look up peer info for message consolidation - self.privateChatManager.unifiedPeerService = self.unifiedPeerService - // Allow UnifiedPeerService to route favorite notifications via mesh/Nostr - self.unifiedPeerService.messageRouter = self.messageRouter - self.autocompleteService = AutocompleteService() - self.deduplicationService = MessageDeduplicationService() + self.commandProcessor = services.commandProcessor + self.messageRouter = services.messageRouter + self.privateChatManager = services.privateChatManager + self.unifiedPeerService = services.unifiedPeerService + self.autocompleteService = services.autocompleteService + self.deduplicationService = services.deduplicationService + self.publicMessagePipeline = services.publicMessagePipeline + self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts() - // Wire up dependencies - self.commandProcessor.contextProvider = self - self.participantTracker.configure(context: self) - - // Subscribe to privateChatManager changes to trigger UI updates - privateChatManager.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - - // Subscribe to participantTracker changes to trigger UI updates - participantTracker.objectWillChange - .sink { [weak self] _ in - self?.objectWillChange.send() - } - .store(in: &cancellables) - self.commandProcessor.meshService = meshService - - loadNickname() - loadVerifiedFingerprints() - meshService.delegate = self - - // Log startup info - - // 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() - - publicMessagePipeline.delegate = self - publicMessagePipeline.updateActiveChannel(activeChannel) - - // Check initial Bluetooth state after a brief delay to allow centralManager initialization - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - guard let self = self else { return } - if let bleService = self.meshService as? BLEService { - let state = bleService.getCurrentBluetoothState() - self.updateBluetoothState(state) - } - } - - // Announce Tor status (geohash-only; do not show in mesh chat). Only when auto-start is allowed. - if TorManager.shared.torEnforced && !torStatusAnnounced && TorManager.shared.isAutoStartAllowed() { - torStatusAnnounced = true - addGeohashOnlySystemMessage( - String(localized: "system.tor.starting", comment: "System message when Tor is starting") - ) - } else if !TorManager.shared.torEnforced && !torStatusAnnounced { - torStatusAnnounced = true - addGeohashOnlySystemMessage( - String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development") - ) - } - - // Initialize Nostr relay manager regardless of Tor readiness; connection is controlled elsewhere - nostrRelayManager = NostrRelayManager.shared - // Attempt to flush any queued outbox (mesh/Nostr routing will gate appropriately) - messageRouter.flushAllOutbox() - // End startup phase after a short delay - 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 peersCancellable = unifiedPeerService.$peers - .receive(on: DispatchQueue.main) - .sink { [weak self] peers in - guard let self = self else { return } - // Update peers directly; @Published drives UI updates - self.allPeers = peers - // Update peer index for O(1) lookups - // Deduplicate peers by ID to prevent crash from duplicate keys - var uniquePeers: [PeerID: BitchatPeer] = [:] - for peer in peers { - // Keep the first occurrence of each peer ID - if uniquePeers[peer.peerID] == nil { - uniquePeers[peer.peerID] = peer - } else { - SecureLogger.warning("⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", category: .session) - } - } - self.peerIndex = uniquePeers - // Update private chat peer ID if needed when peers change - if self.selectedPrivateChatFingerprint != nil { - self.updatePrivateChatPeerIfNeeded() - } - } - self.cancellables.insert(peersCancellable) - - // 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 - // Set up DM handler once on first connect - if !self.nostrHandlersSetup { - self.setupNostrMessageHandling() - self.nostrHandlersSetup = true - } - self.resubscribeCurrentGeohash() - // Re-init sampling for regional + bookmarked geohashes after reconnect - self.geoChannelCoordinator?.refreshSampling() - } - } - } - .store(in: &self.cancellables) - } - - // Set up Noise encryption callbacks - setupNoiseCallbacks() - - TransferProgressManager.shared.publisher - .receive(on: DispatchQueue.main) - .sink { [weak self] event in - self?.handleTransferEvent(event) - } - .store(in: &cancellables) - - geoChannelCoordinator = GeoChannelCoordinator( - onChannelSwitch: { [weak self] channel in - self?.switchLocationChannel(to: channel) - }, - beginSampling: { [weak self] geohashes in - self?.beginGeohashSampling(for: geohashes) - }, - endSampling: { [weak self] in - self?.endGeohashSampling() - } - ) - - // Track teleport flag changes to keep our own teleported marker in sync with regional status - LocationChannelManager.shared.$teleported - .receive(on: DispatchQueue.main) - .sink { [weak self] isTeleported in - guard let self = self else { return } - Task { @MainActor in - guard case .location(let ch) = self.activeChannel, - let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } - let key = id.publicKeyHex.lowercased() - let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } - if isTeleported && hasRegional && !inRegional { - self.teleportedGeo = self.teleportedGeo.union([key]) - } else { - self.teleportedGeo.remove(key) - } - } - } - .store(in: &cancellables) - - // Request notification permission (guards test environment internally) - 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 - ) - - // When app becomes active, send read receipts for visible messages - #if os(macOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidBecomeActive), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - - // Add app lifecycle observers to save data - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: NSApplication.willResignActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationWillTerminate), - name: NSApplication.willTerminateNotification, - object: nil - ) - // Tor lifecycle notifications: inform user when Tor restarts and when ready (macOS) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorWillRestart), - name: .TorWillRestart, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorDidBecomeReady), - name: .TorDidBecomeReady, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorWillStart), - name: .TorWillStart, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorPreferenceChanged(_:)), - name: .TorUserPreferenceChanged, - object: nil - ) - #else - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) - // Resubscribe geohash on app foreground - // Resubscribe handled via appDidBecomeActive selector - - // Add screenshot detection for iOS - NotificationCenter.default.addObserver( - self, - selector: #selector(userDidTakeScreenshot), - name: UIApplication.userDidTakeScreenshotNotification, - object: nil - ) - - // Add app lifecycle observers to save data - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil - ) - // Tor lifecycle notifications: inform user when Tor restarts and when ready - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorWillRestart), - name: .TorWillRestart, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorDidBecomeReady), - name: .TorDidBecomeReady, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorWillStart), - name: .TorWillStart, - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTorPreferenceChanged(_:)), - name: .TorUserPreferenceChanged, - object: nil - ) - #endif + ChatViewModelBootstrapper(viewModel: self).configure() + initializeConversationStore() } // MARK: - Deinitialization @@ -747,7 +502,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Nickname Management - private func loadNickname() { + func loadNickname() { if let savedNickname = userDefaults.string(forKey: nicknameKey) { nickname = savedNickname.trimmed } else { @@ -901,83 +656,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { - return unifiedPeerService.isBlocked(peerID) - } - - // Helper method to find current peer ID for a fingerprint - @MainActor - private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> PeerID? { - // Search through all connected peers to find the one with matching fingerprint - for peerID in connectedPeers { - if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID], - mappedFingerprint == fingerprint { - return peerID - } - } - return nil + peerIdentityCoordinator.isPeerBlocked(peerID) } // Helper method to update selectedPrivateChatPeer if fingerprint matches @MainActor - private func updatePrivateChatPeerIfNeeded() { - guard let chatFingerprint = selectedPrivateChatFingerprint else { return } - - // Find current peer ID for the fingerprint - if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) { - // Update the selected peer if it's different - if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID { - - // Migrate messages from old peer ID to new peer ID - if let oldMessages = privateChats[oldPeerID] { - var chats = privateChats - if chats[currentPeerID] == nil { - chats[currentPeerID] = [] - } - chats[currentPeerID]?.append(contentsOf: oldMessages) - // Sort by timestamp - chats[currentPeerID]?.sort { $0.timestamp < $1.timestamp } - - // Remove duplicates - var seen = Set() - chats[currentPeerID] = chats[currentPeerID]?.filter { msg in - if seen.contains(msg.id) { - return false - } - seen.insert(msg.id) - return true - } - - // Remove old peer ID - chats.removeValue(forKey: oldPeerID) - - // Update all at once - privateChats = chats // Trigger setter - } - - // Migrate unread status - if unreadPrivateMessages.contains(oldPeerID) { - unreadPrivateMessages.remove(oldPeerID) - unreadPrivateMessages.insert(currentPeerID) - } - - selectedPrivateChatPeer = currentPeerID - - // Schedule UI update for encryption status change - // UI will update automatically - - // Also refresh the peer list to update encryption status - Task { @MainActor in - // UnifiedPeerService updates automatically via subscriptions - } - } else if selectedPrivateChatPeer == nil { - // Just set the peer ID if we don't have one - selectedPrivateChatPeer = currentPeerID - // UI will update automatically - } - - // Clear unread messages for the current peer ID - unreadPrivateMessages.remove(currentPeerID) - } + func updatePrivateChatPeerIfNeeded() { + peerIdentityCoordinator.updatePrivateChatPeerIfNeeded() } // MARK: - Message Sending @@ -988,121 +673,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Routes to private chat if one is selected, otherwise broadcasts @MainActor func sendMessage(_ content: String) { - // Ignore messages that are empty or whitespace-only to prevent blank lines - guard let trimmed = content.trimmedOrNilIfEmpty else { return } - - // Check for commands - if content.hasPrefix("/") { - Task { @MainActor in - handleCommand(content) - } - return - } - - if selectedPrivateChatPeer != nil { - // Update peer ID in case it changed due to reconnection - updatePrivateChatPeerIfNeeded() - - if let selectedPeer = selectedPrivateChatPeer { - sendPrivateMessage(content, to: selectedPeer) - } - return - } - - // Parse mentions from the content (use original content for user intent) - let mentions = parseMentions(from: content) - - var geoContext: GeoOutgoingContext? = nil - - // Add message to local display - var displaySender = nickname - var localSenderPeerID = meshService.myPeerID - var messageID: String? = nil - var messageTimestamp = Date() - - switch activeChannel { - case .mesh: - break - case .location(let ch): - do { - let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = nickname + "#" + suffix - localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - let teleported = LocationChannelManager.shared.teleported - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: trimmed, - geohash: ch.geohash, - senderIdentity: identity, - nickname: nickname, - teleported: teleported - ) - messageID = event.id - messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - geoContext = (channel: ch, event: event, identity: identity, teleported: teleported) - } catch { - SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session) - addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return - } - } - - let message = BitchatMessage( - id: messageID, - sender: displaySender, - content: trimmed, - timestamp: messageTimestamp, - isRelay: false, - senderPeerID: localSenderPeerID, - mentions: mentions.isEmpty ? nil : mentions - ) - - timelineStore.append(message, to: activeChannel) - refreshVisibleMessages(from: activeChannel) - - // Update content LRU for near-dup detection - let ckey = deduplicationService.normalizedContentKey(message.content) - deduplicationService.recordContentKey(ckey, timestamp: message.timestamp) - - trimMessagesIfNeeded() - - // UI updates automatically via @Published var messages - - updateChannelActivityTimeThenSend(content: content, - trimmed: trimmed, - mentions: mentions, - geoContext: geoContext, - messageID: message.id, - timestamp: message.timestamp) - } - - private func updateChannelActivityTimeThenSend(content: String, - trimmed: String, - mentions: [String], - geoContext: GeoOutgoingContext?, - messageID: String, - timestamp: Date) { - switch activeChannel { - case .mesh: - lastPublicActivityAt["mesh"] = Date() - // Send via mesh with mentions - meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) - case .location(let ch): - lastPublicActivityAt["geo:\(ch.geohash)"] = Date() - guard let context = geoContext, context.channel.geohash == ch.geohash else { - SecureLogger.error("Geo: missing send context for \(ch.geohash)", category: .session) - addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - return - } - // Send to geohash channel via Nostr ephemeral - Task { @MainActor in - self.sendGeohash(context: context) - } - } + outgoingCoordinator.sendMessage(content) } @@ -1148,103 +719,53 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { - participantTracker.getVisiblePeople() + publicConversationCoordinator.visibleGeohashPeople() } /// CommandContextProvider conformance - returns visible geo participants func getVisibleGeoParticipants() -> [CommandGeoParticipant] { - visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) } + publicConversationCoordinator.getVisibleGeoParticipants() } /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { - participantTracker.participantCount(for: geohash) + publicConversationCoordinator.geohashParticipantCount(for: geohash) } // MARK: - GeohashParticipantContext Protocol func displayNameForPubkey(_ pubkeyHex: String) -> String { - displayNameForNostrPubkey(pubkeyHex) + publicConversationCoordinator.displayNameForPubkey(pubkeyHex) } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { - identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + publicConversationCoordinator.isBlocked(pubkeyHexLowercased) } // Geohash block helpers @MainActor func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { - return identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + publicConversationCoordinator.isGeohashUserBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } @MainActor func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - let hex = pubkeyHexLowercased.lowercased() - identityManager.setNostrBlocked(hex, isBlocked: true) - - // Remove from participants for all geohashes - participantTracker.removeParticipant(pubkeyHex: hex) - - // Remove their public messages from current geohash timeline and visible list - if let gh = currentGeohash { - let predicate: (BitchatMessage) -> Bool = { [self] msg in - guard let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat else { return false } - if let full = self.nostrKeyMapping[spid]?.lowercased() { return full == hex } - return false - } - timelineStore.removeMessages(in: gh, where: predicate) - if case .location = activeChannel { - messages.removeAll(where: predicate) - } - } - - // Remove geohash DM conversation if exists - let convKey = PeerID(nostr_: hex) - if privateChats[convKey] != nil { - privateChats.removeValue(forKey: convKey) - unreadPrivateMessages.remove(convKey) - } - - // Remove mapping keys pointing to this pubkey to avoid accidental resolution - for (key, value) in self.nostrKeyMapping where value.lowercased() == hex { - self.nostrKeyMapping.removeValue(forKey: key) - } - - addSystemMessage( - String( - format: String(localized: "system.geohash.blocked", comment: "System message shown when a user is blocked in geohash chats"), - locale: .current, - displayName - ) + publicConversationCoordinator.blockGeohashUser( + pubkeyHexLowercased: pubkeyHexLowercased, + displayName: displayName ) } @MainActor func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) - addSystemMessage( - String( - format: String(localized: "system.geohash.unblocked", comment: "System message shown when a user is unblocked in geohash chats"), - locale: .current, - displayName - ) + publicConversationCoordinator.unblockGeohashUser( + pubkeyHexLowercased: pubkeyHexLowercased, + displayName: displayName ) } func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { - let suffix = String(pubkeyHex.suffix(4)) - // If this is our per-geohash identity, use our nickname - if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) { - if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { - return nickname + "#" + suffix - } - } - // If we have a known nickname tag for this pubkey, use it - if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { - return nick + "#" + suffix - } - // Otherwise, anonymous with collision-resistant suffix - return "anon#\(suffix)" + publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) } @@ -1265,69 +786,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv func currentPublicSender() -> (name: String, peerID: PeerID) { - var displaySender = nickname - var senderPeerID = meshService.myPeerID - if case .location(let ch) = activeChannel, - let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = nickname + "#" + suffix - senderPeerID = PeerID(nostr: identity.publicKeyHex) - } - return (displaySender, senderPeerID) + publicConversationCoordinator.currentPublicSender() } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { - if let name = meshService.peerNickname(peerID: peerID) { - return name - } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), - !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - if let noiseKey = Data(hexString: peerID.id), - let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - !favorite.peerNickname.isEmpty { - return favorite.peerNickname - } - return "user" + peerIdentityCoordinator.nicknameForPeer(peerID) } @MainActor func removeMessage(withID messageID: String, cleanupFile: Bool = false) { - var removedMessage: BitchatMessage? - - if let idx = messages.firstIndex(where: { $0.id == messageID }) { - removedMessage = messages.remove(at: idx) - } - - if let storeRemoved = timelineStore.removeMessage(withID: messageID) { - removedMessage = removedMessage ?? storeRemoved - } - - var chats = privateChats - for (peerID, items) in chats { - let filtered = items.filter { $0.id != messageID } - if filtered.count != items.count { - if filtered.isEmpty { - chats.removeValue(forKey: peerID) - } else { - chats[peerID] = filtered - } - if removedMessage == nil { - removedMessage = items.first(where: { $0.id == messageID }) - } - } - } - privateChats = chats - - if cleanupFile, let message = removedMessage { - cleanupLocalFile(forMessage: message) - } - - objectWillChange.send() + publicConversationCoordinator.removeMessage(withID: messageID, cleanupFile: cleanupFile) } @@ -1386,322 +857,42 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// - Note: Switches the UI to private chat mode and loads message history @MainActor func startPrivateChat(with peerID: PeerID) { - // Safety check: Don't allow starting chat with ourselves - if peerID == meshService.myPeerID { - return - } - - let peerNickname = meshService.peerNickname(peerID: peerID) ?? "unknown" - - // Check if the peer is blocked - if unifiedPeerService.isBlocked(peerID) { - addSystemMessage( - String( - format: String(localized: "system.chat.blocked", comment: "System message when starting chat fails because peer is blocked"), - locale: .current, - peerNickname - ) - ) - return - } - - // Check mutual favorites for offline messaging - if let peer = unifiedPeerService.getPeer(by: peerID), - peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - addSystemMessage( - String( - format: String(localized: "system.chat.requires_favorite", comment: "System message when mutual favorite requirement blocks chat"), - locale: .current, - peerNickname - ) - ) - return - } - - // Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs) - // Pass persisted sentReadReceipts to correctly identify already-read messages after app restart - _ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts) - - // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. - if !peerID.isGeoDM && !peerID.isGeoChat { - let sessionState = meshService.getNoiseSessionState(for: peerID) - switch sessionState { - case .none, .failed: - meshService.triggerHandshake(with: peerID) - case .handshakeQueued, .handshaking, .established: - break - } - } else { - SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) - } - - // Sync read receipt tracking to prevent duplicates - privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts) - - privateChatManager.startChat(with: peerID) - - // Also mark messages as read for Nostr ACKs - // This ensures read receipts are sent even for consolidated messages - markPrivateMessagesAsRead(from: peerID) - } - - func endPrivateChat() { - selectedPrivateChatPeer = nil - selectedPrivateChatFingerprint = nil + peerIdentityCoordinator.startPrivateChat(with: peerID) } @MainActor - @objc private func handlePeerStatusUpdate(_ notification: Notification) { - // Update private chat peer if needed when peer status changes - updatePrivateChatPeerIfNeeded() + func endPrivateChat() { + peerIdentityCoordinator.endPrivateChat() } - @objc private func handleFavoriteStatusChanged(_ notification: Notification) { - guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } - - Task { @MainActor in - // Handle noise key updates - if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, - isKeyUpdate, - let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data { - let oldPeerID = PeerID(hexData: oldKey) - let newPeerID = PeerID(hexData: peerPublicKey) - - // If we have a private chat open with the old peer ID, update it to the new one - if selectedPrivateChatPeer == oldPeerID { - SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) - - // Transfer private chat messages to new peer ID - if let messages = privateChats[oldPeerID] { - var chats = privateChats - chats[newPeerID] = messages - chats.removeValue(forKey: oldPeerID) - privateChats = chats // Trigger setter - } - - // Transfer unread status - if unreadPrivateMessages.contains(oldPeerID) { - unreadPrivateMessages.remove(oldPeerID) - unreadPrivateMessages.insert(newPeerID) - } - - // Update selected peer - selectedPrivateChatPeer = newPeerID - - // Update fingerprint tracking if needed - if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { - peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) - peerIDToPublicKeyFingerprint[newPeerID] = fingerprint - selectedPrivateChatFingerprint = fingerprint - } - - // Schedule UI refresh - // UI will update automatically - } else { - // Even if the chat isn't open, migrate any existing private chat data - if let messages = privateChats[oldPeerID] { - SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) - var chats = privateChats - chats[newPeerID] = messages - chats.removeValue(forKey: oldPeerID) - privateChats = chats // Trigger setter - } - - // Transfer unread status - if unreadPrivateMessages.contains(oldPeerID) { - unreadPrivateMessages.remove(oldPeerID) - unreadPrivateMessages.insert(newPeerID) - } - - // Update fingerprint mapping - if let fingerprint = peerIDToPublicKeyFingerprint[oldPeerID] { - peerIDToPublicKeyFingerprint.removeValue(forKey: oldPeerID) - peerIDToPublicKeyFingerprint[newPeerID] = fingerprint - } - } - } - - // First check if this is a peer ID update for our current chat - updatePrivateChatPeerIfNeeded() - - // Then handle favorite/unfavorite messages if applicable - if let isFavorite = notification.userInfo?["isFavorite"] as? Bool { - let peerID = PeerID(hexData: peerPublicKey) - let action = isFavorite ? "favorited" : "unfavorited" - - // Find peer nickname - let peerNickname: String - if let nickname = meshService.peerNickname(peerID: peerID) { - peerNickname = nickname - } else if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { - peerNickname = favorite.peerNickname - } else { - peerNickname = "Unknown" - } - - // Create system message - let systemMessage = BitchatMessage( - id: UUID().uuidString, - sender: "System", - content: "\(peerNickname) \(action) you", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: nil, - mentions: nil - ) - - // Add to message stream - addMessage(systemMessage) - - // Update peer manager to refresh UI - // UnifiedPeerService updates automatically via subscriptions - } - } + @MainActor + @objc func handlePeerStatusUpdate(_ notification: Notification) { + peerIdentityCoordinator.handlePeerStatusUpdate() + } + + @objc func handleFavoriteStatusChanged(_ notification: Notification) { + peerIdentityCoordinator.handleFavoriteStatusChanged(notification) } // MARK: - App Lifecycle @MainActor - @objc private func appDidBecomeActive() { - // Check Bluetooth state and show alert if needed - if let bleService = meshService as? BLEService { - let currentState = bleService.getCurrentBluetoothState() - updateBluetoothState(currentState) - } - - // When app becomes active, send read receipts for visible private chat - if let peerID = selectedPrivateChatPeer { - // Try immediately - self.markPrivateMessagesAsRead(from: peerID) - // And again with a delay - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { - self.markPrivateMessagesAsRead(from: peerID) - } - } - // Subscriptions will be resent after connections come back up + func handleDidBecomeActive() { + lifecycleCoordinator.handleDidBecomeActive() } @MainActor - @objc private func userDidTakeScreenshot() { - // Respect privacy: do not broadcast screenshots taken from non-chat sheets - if isLocationChannelsSheetPresented { - // Show a warning about sharing location screenshots publicly - showScreenshotPrivacyWarning = true - return - } - if isAppInfoPresented { - // Silently ignore screenshots of app info - return - } - - // Send screenshot notification based on current context - let screenshotMessage = "* \(nickname) took a screenshot *" - - if let peerID = selectedPrivateChatPeer { - // In private chat - send to the other person - if let peerNickname = meshService.peerNickname(peerID: peerID) { - // Only send screenshot notification if we have an established session - // This prevents triggering handshake requests for screenshot notifications - let sessionState = meshService.getNoiseSessionState(for: peerID) - switch sessionState { - case .established: - // Send the message directly without going through sendPrivateMessage to avoid local echo - messageRouter.sendPrivate(screenshotMessage, to: peerID, recipientNickname: peerNickname, messageID: UUID().uuidString) - case .none, .failed, .handshakeQueued, .handshaking: - // Don't send screenshot notification if no session exists - SecureLogger.debug("Skipping screenshot notification to \(peerID) - no established session", category: .security) - } - } - - // Show local notification immediately as system message (only in chat) - let localNotification = BitchatMessage( - sender: "system", - content: "you took a screenshot", - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: meshService.peerNickname(peerID: peerID), - senderPeerID: meshService.myPeerID - ) - var chats = privateChats - if chats[peerID] == nil { - chats[peerID] = [] - } - chats[peerID]?.append(localNotification) - privateChats = chats // Trigger setter - - } else { - // In public chat - send to active public channel - switch activeChannel { - case .mesh: - meshService.sendMessage(screenshotMessage, - mentions: [], - messageID: UUID().uuidString, - timestamp: Date()) - case .location(let ch): - Task { @MainActor in - do { - let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: screenshotMessage, - geohash: ch.geohash, - senderIdentity: identity, - nickname: self.nickname, - teleported: LocationChannelManager.shared.teleported - ) - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - // Track ourselves as active participant - self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) - } catch { - SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) - self.addSystemMessage( - String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") - ) - } - } - } - - - // Show local notification immediately as system message (only in chat) - let localNotification = BitchatMessage( - sender: "system", - content: "you took a screenshot", - timestamp: Date(), - isRelay: false - ) - // Add system message - addMessage(localNotification) - } - } - - @objc private func appWillResignActive() { - // No-op; avoid forcing synchronize on resign + func handleScreenshotCaptured() { + lifecycleCoordinator.handleScreenshotCaptured() } /// Save identity state without stopping services (for backgrounding) func saveIdentityState() { - // Force save any pending identity changes (verifications, favorites, etc) - identityManager.forceSave() - - // Verify identity key is still there - _ = keychain.verifyIdentityKeyExists() + lifecycleCoordinator.saveIdentityState() } @objc func applicationWillTerminate() { - // Send leave message to all peers - meshService.stopServices() - - // Save identity state - saveIdentityState() + lifecycleCoordinator.applicationWillTerminate() } @MainActor @@ -1737,155 +928,21 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @MainActor func markPrivateMessagesAsRead(from peerID: PeerID) { - privateChatManager.markAsRead(from: peerID) - - // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity - if peerID.isGeoDM, - let recipientHex = nostrKeyMapping[peerID], - case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let messages = privateChats[peerID] ?? [] - for message in messages where message.senderPeerID == peerID && !message.isRelay { - if !sentReadReceipts.contains(message.id) { - SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session) - let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) - sentReadReceipts.insert(message.id) - } - } - return - } - - // Get the peer's Noise key to check for Nostr messages - var noiseKeyHex: PeerID? = nil - var peerNostrPubkey: String? = nil - - // First check if peerID is already a hex Noise key - if let noiseKey = Data(hexString: peerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { - noiseKeyHex = peerID - peerNostrPubkey = favoriteStatus.peerNostrPublicKey - } - // Otherwise get the Noise key from the peer info - else if let peer = unifiedPeerService.getPeer(by: peerID) { - noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) - peerNostrPubkey = favoriteStatus?.peerNostrPublicKey - - // Also remove unread status from the stable Noise key if it exists - if let keyHex = noiseKeyHex, unreadPrivateMessages.contains(keyHex) { - unreadPrivateMessages.remove(keyHex) - } - } - - // Send Nostr read ACKs if peer has Nostr capability - if peerNostrPubkey != nil { - // Check messages under both ephemeral peer ID and stable Noise key - let messagesToAck = getPrivateChatMessages(for: peerID) - - for message in messagesToAck { - // Only send read ACKs for messages from the peer (not our own) - // Check both the ephemeral peer ID and stable Noise key as sender - if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay { - // Skip if we already sent an ACK for this message - if !sentReadReceipts.contains(message.id) { - // Use stable Noise key hex if available; else fall back to peerID - let recipPeer = peerID.isHex ? peerID : (unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) - let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) - messageRouter.sendReadReceipt(receipt, to: recipPeer) - sentReadReceipts.insert(message.id) - } - } - } - } + lifecycleCoordinator.markPrivateMessagesAsRead(from: peerID) } func getMessages(for peerID: PeerID?) -> [BitchatMessage] { - if let peerID { - return getPrivateChatMessages(for: peerID) - } else { - return messages - } + lifecycleCoordinator.getMessages(for: peerID) } @MainActor func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { - var combined: [BitchatMessage] = [] - - // Gather messages under the ephemeral peer ID - if let ephemeralMessages = privateChats[peerID] { - combined.append(contentsOf: ephemeralMessages) - } - - // Also include messages stored under the stable Noise key (Nostr path) - if let peer = unifiedPeerService.getPeer(by: peerID) { - let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] { - combined.append(contentsOf: nostrMessages) - } - } - - // De-duplicate by message ID: keep the item with the most advanced delivery status. - // This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures - // we show the row whose status has already progressed to delivered/read. - func statusRank(_ s: DeliveryStatus?) -> Int { - guard let s = s else { return 0 } - switch s { - case .failed: return 1 - case .sending: return 2 - case .sent: return 3 - case .partiallyDelivered: return 4 - case .delivered: return 5 - case .read: return 6 - } - } - - var bestByID: [String: BitchatMessage] = [:] - for msg in combined { - if let existing = bestByID[msg.id] { - let lhs = statusRank(existing.deliveryStatus) - let rhs = statusRank(msg.deliveryStatus) - if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) { - bestByID[msg.id] = msg - } - } else { - bestByID[msg.id] = msg - } - } - - // Return chronologically sorted, de-duplicated list - return bestByID.values.sorted { $0.timestamp < $1.timestamp } + lifecycleCoordinator.getPrivateChatMessages(for: peerID) } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { - // When in a geohash channel, allow resolving by geohash participant nickname - switch LocationChannelManager.shared.selectedChannel { - case .location: - // If a disambiguation suffix is present (e.g., "name#abcd"), try exact displayName match first - if nickname.contains("#") { - if let person = visibleGeohashPeople().first(where: { $0.displayName == nickname }) { - let convKey = PeerID(nostr_: person.id) - nostrKeyMapping[convKey] = person.id - return convKey - } - } - let base: String = { - if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[.. nickname) - if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { - let convKey = PeerID(nostr_: pub) - nostrKeyMapping[convKey] = pub - return convKey - } - case .mesh: - break - } - // Fallback to mesh nickname resolution - return unifiedPeerService.getPeerID(for: nickname) + peerIdentityCoordinator.getPeerIDForNickname(nickname) } @@ -1898,6 +955,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Clear all messages messages.removeAll() + timelineStore = PublicTimelineStore( + meshCap: TransportConfig.meshTimelineCap, + geohashCap: TransportConfig.geoTimelineCap + ) privateChatManager.privateChats.removeAll() privateChatManager.unreadMessages.removeAll() @@ -1908,10 +969,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey") userDefaults.removeObject(forKey: "bitchat.messageRetentionKey") - // Clear verified fingerprints - verifiedFingerprints.removeAll() - // Verified fingerprints are cleared when identity data is cleared below - // Reset nickname to anonymous nickname = "anon\(Int.random(in: 1000...9999))" saveNickname() @@ -1919,7 +976,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Clear favorites and peer mappings // Clear through SecureIdentityStateManager instead of directly identityManager.clearAllIdentityData() - peerIDToPublicKeyFingerprint.removeAll() + peerIdentityStore.clearAll() + locationPresenceStore.reset() // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() @@ -1934,15 +992,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Clear selected private chat selectedPrivateChatPeer = nil - selectedPrivateChatFingerprint = nil // Clear read receipt tracking sentReadReceipts.removeAll() deduplicationService.clearAll() - // Clear all caches - invalidateEncryptionCache() - // IMPORTANT: Clear Nostr-related state // Disconnect from Nostr relays and clear subscriptions nostrRelayManager?.disconnect() @@ -1957,7 +1011,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv if let bleService = meshService as? BLEService { bleService.resetIdentityForPanic(currentNickname: nickname) } - + + initializeConversationStore() + // No need to force UserDefaults synchronization // Reinitialize Nostr with new identity @@ -2034,555 +1090,89 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Autocomplete func updateAutocomplete(for text: String, cursorPosition: Int) { - // Build candidate list based on active channel - let peerCandidates: [String] = { - switch activeChannel { - case .mesh: - let values = meshService.getPeerNicknames().values - return Array(values.filter { $0 != meshService.myNickname }) - case .location(let ch): - // From geochash participants we have seen via Nostr events - var tokens = Set() - for (pubkey, nick) in geoNicknames { - let suffix = String(pubkey.suffix(4)) - tokens.insert("\(nick)#\(suffix)") - } - // Optionally exclude self nick#abcd from suggestions - if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4)) - tokens.remove(myToken) - } - return Array(tokens) - } - }() - - let (suggestions, range) = autocompleteService.getSuggestions( - for: text, - peers: peerCandidates, - cursorPosition: cursorPosition - ) - - if !suggestions.isEmpty { - autocompleteSuggestions = suggestions - autocompleteRange = range - showAutocomplete = true - selectedAutocompleteIndex = 0 - } else { - autocompleteSuggestions = [] - autocompleteRange = nil - showAutocomplete = false - selectedAutocompleteIndex = 0 - } + composerCoordinator.updateAutocomplete(for: text, cursorPosition: cursorPosition) } func completeNickname(_ nickname: String, in text: inout String) -> Int { - guard let range = autocompleteRange else { return text.count } - - text = autocompleteService.applySuggestion(nickname, to: text, range: range) - - // Hide autocomplete - showAutocomplete = false - autocompleteSuggestions = [] - autocompleteRange = nil - selectedAutocompleteIndex = 0 - - // Return new cursor position - return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) + composerCoordinator.completeNickname(nickname, in: &text) } // MARK: - Message Formatting @MainActor func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - // Determine if this message was sent by self (mesh, geo, or DM) - let isSelf: Bool = { - if let spid = message.senderPeerID { - // In geohash channels, compare against our per-geohash nostr short ID - if case .location(let ch) = activeChannel, spid.isGeoChat { - let myGeo: NostrIdentity? = { - if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { - return cached.identity - } - // Fallback: derive and cache (should rarely happen) - if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - cachedGeohashIdentity = (ch.geohash, identity) - return identity - } - return nil - }() - if let myGeo { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - } - return spid == meshService.myPeerID - } - // Fallback by nickname - if message.sender == nickname { return true } - if message.sender.hasPrefix(nickname + "#") { return true } - return false - }() - // Check cache first (key includes dark mode + self flag) - let isDark = colorScheme == .dark - if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { - return cachedText - } - - // Not cached, format the message - var result = AttributedString() - - let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) - - if message.sender != "system" { - // Sender (at the beginning) with light-gray suffix styling if present - let (baseName, suffix) = message.sender.splitSuffix() - var senderStyle = AttributeContainer() - // Use consistent color for all senders - senderStyle.foregroundColor = baseColor - // Bold the user's own nickname - let fontWeight: Font.Weight = isSelf ? .bold : .medium - senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) - // Make sender clickable: encode senderPeerID into a custom URL - if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { - senderStyle.link = url - } - - // Prefix "<@" - result.append(AttributedString("<@").mergingAttributes(senderStyle)) - // Base name - result.append(AttributedString(baseName).mergingAttributes(senderStyle)) - // Optional suffix in lighter variant of the base color (green or orange for self) - if !suffix.isEmpty { - var suffixStyle = senderStyle - suffixStyle.foregroundColor = baseColor.opacity(0.6) - result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) - } - // Suffix "> " - result.append(AttributedString("> ").mergingAttributes(senderStyle)) - - // Process content with hashtags and mentions - let content = message.content - - // For extremely long content, render as plain text to avoid heavy regex/layout work, - // unless the content includes Cashu tokens we want to chip-render below - // Compute NSString-backed length for regex/nsrange correctness with multi-byte characters - let nsContent = content as NSString - let nsLen = nsContent.length - let containsCashuEarly: Bool = { - let rx = Patterns.quickCashuPresence - return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 - }() - if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { - var plainStyle = AttributeContainer() - plainStyle.foregroundColor = baseColor - plainStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) - result.append(AttributedString(content).mergingAttributes(plainStyle)) - } else { - // Reuse compiled regexes and detector from MessageFormattingEngine - let hashtagRegex = Patterns.hashtag - let mentionRegex = Patterns.mention - let cashuRegex = Patterns.cashu - let bolt11Regex = Patterns.bolt11 - let lnurlRegex = Patterns.lnurl - let lightningSchemeRegex = Patterns.lightningScheme - let detector = Patterns.linkDetector - let hasMentionsHint = content.contains("@") - let hasHashtagsHint = content.contains("#") - let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") - let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") - let hasCashuHint = content.lowercased().contains("cashu") - - let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : [] - let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] - - // Combine and sort matches, excluding hashtags/URLs overlapping mentions - let mentionRanges = mentionMatches.map { $0.range(at: 0) } - func overlapsMention(_ r: NSRange) -> Bool { - for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } } - return false - } - // Helper: check if a hashtag is immediately attached to a preceding @mention (e.g., @name#abcd) - func attachedToMention(_ r: NSRange) -> Bool { - if let nsRange = Range(r, in: content), nsRange.lowerBound > content.startIndex { - var i = content.index(before: nsRange.lowerBound) - while true { - let ch = content[i] - if ch.isWhitespace || ch.isNewline { break } - if ch == "@" { return true } - if i == content.startIndex { break } - i = content.index(before: i) - } - } - return false - } - // Helper: ensure '#' starts a new token (start-of-line or whitespace before '#') - func isStandaloneHashtag(_ r: NSRange) -> Bool { - guard let nsRange = Range(r, in: content) else { return false } - if nsRange.lowerBound == content.startIndex { return true } - let prev = content.index(before: nsRange.lowerBound) - return content[prev].isWhitespace || content[prev].isNewline - } - var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "hashtag")) - } - for match in mentionMatches { - allMatches.append((match.range(at: 0), "mention")) - } - for match in urlMatches where !overlapsMention(match.range) { - allMatches.append((match.range, "url")) - } - for match in cashuMatches where !overlapsMention(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "cashu")) - } - // Lightning scheme first to avoid overlapping submatches - for match in lightningMatches where !overlapsMention(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "lightning")) - } - // Exclude overlaps with lightning/url for bolt11/lnurl - let occupied: [NSRange] = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) } - func overlapsOccupied(_ r: NSRange) -> Bool { - for or in occupied { if NSIntersectionRange(r, or).length > 0 { return true } } - return false - } - for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "bolt11")) - } - for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { - allMatches.append((match.range(at: 0), "lnurl")) - } - allMatches.sort { $0.range.location < $1.range.location } - - // Build content with styling - var lastEnd = content.startIndex - let isMentioned = message.mentions?.contains(nickname) ?? false - - for (range, type) in allMatches { - // Add text before match - if let nsRange = Range(range, in: content) { - if lastEnd < nsRange.lowerBound { - let beforeText = String(content[lastEnd..) - let token = String(matchText.dropFirst()).lowercased() - let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") - let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) } - // Do not link if this hashtag is directly attached to an @mention (e.g., @name#geohash) - let attachedToMention: Bool = { - // nsRange is the Range for this match within content - // Walk left until whitespace/newline; if we encounter '@' first, treat as part of mention - if nsRange.lowerBound > content.startIndex { - var i = content.index(before: nsRange.lowerBound) - while true { - let ch = content[i] - if ch.isWhitespace || ch.isNewline { break } - if ch == "@" { return true } - if i == content.startIndex { break } - i = content.index(before: i) - } - } - return false - }() - // Also require the '#' to start a new token (whitespace or start-of-line before '#') - let standalone: Bool = { - if nsRange.lowerBound == content.startIndex { return true } - let prev = content.index(before: nsRange.lowerBound) - return content[prev].isWhitespace || content[prev].isNewline - }() - var tagStyle = AttributeContainer() - tagStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) - tagStyle.foregroundColor = baseColor - if isGeohash && !attachedToMention && standalone, let url = URL(string: "bitchat://geohash/\(token)") { - tagStyle.link = url - tagStyle.underlineStyle = .single - } - result.append(AttributedString(matchText).mergingAttributes(tagStyle)) - } else if type == "cashu" { - // Skip inline token; a styled chip is rendered below the message - // We insert a single space to avoid words sticking together - var spacer = AttributeContainer() - spacer.foregroundColor = baseColor - spacer.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) - result.append(AttributedString(" ").mergingAttributes(spacer)) - } else if type == "lightning" || type == "bolt11" || type == "lnurl" { - // Skip inline invoice/link; a styled chip is rendered below the message - var spacer = AttributeContainer() - spacer.foregroundColor = baseColor - spacer.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) - result.append(AttributedString(" ").mergingAttributes(spacer)) - } else { - // Keep URL styling and make it tappable via .link attribute - var matchStyle = AttributeContainer() - matchStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .semibold, design: .monospaced) - if type == "url" { - matchStyle.foregroundColor = isSelf ? .orange : .blue - matchStyle.underlineStyle = .single - if let url = URL(string: matchText) { - matchStyle.link = url - } - } - result.append(AttributedString(matchText).mergingAttributes(matchStyle)) - } - } - // Advance lastEnd safely in case of overlaps - if lastEnd < nsRange.upperBound { - lastEnd = nsRange.upperBound - } - } - } - - // Add remaining text - if lastEnd < content.endIndex { - let remainingText = String(content[lastEnd...]) - var remainingStyle = AttributeContainer() - remainingStyle.foregroundColor = baseColor - remainingStyle.font = isSelf - ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) - : .bitchatSystem(size: 14, design: .monospaced) - if isMentioned { - remainingStyle.font = remainingStyle.font?.bold() - } - result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) - } - } - - // Add timestamp at the end (smaller, light grey) - let timestamp = AttributedString(" [\(message.formattedTimestamp)]") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = Color.gray.opacity(0.7) - timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) - result.append(timestamp.mergingAttributes(timestampStyle)) - } else { - // System message - var contentStyle = AttributeContainer() - contentStyle.foregroundColor = Color.gray - let content = AttributedString("* \(message.content) *") - contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic() - result.append(content.mergingAttributes(contentStyle)) - - // Add timestamp at the end for system messages too - let timestamp = AttributedString(" [\(message.formattedTimestamp)]") - var timestampStyle = AttributeContainer() - timestampStyle.foregroundColor = Color.gray.opacity(0.5) - timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced) - result.append(timestamp.mergingAttributes(timestampStyle)) - } - - // Cache the formatted text - message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) - - return result + messageFormatter.formatMessageAsText(message, colorScheme: colorScheme) } @MainActor func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { - let isSelf: Bool = { - if let spid = message.senderPeerID { - if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") { - if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - } - return spid == meshService.myPeerID - } - if message.sender == nickname { return true } - if message.sender.hasPrefix(nickname + "#") { return true } - return false - }() - - let isDark = colorScheme == .dark - let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) - - if message.sender == "system" { - var style = AttributeContainer() - style.foregroundColor = baseColor - style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) - return AttributedString(message.sender).mergingAttributes(style) - } - - var result = AttributedString() - let (baseName, suffix) = message.sender.splitSuffix() - var senderStyle = AttributeContainer() - senderStyle.foregroundColor = baseColor - senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced) - if let spid = message.senderPeerID, - let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { - senderStyle.link = url - } - - result.append(AttributedString("<@").mergingAttributes(senderStyle)) - result.append(AttributedString(baseName).mergingAttributes(senderStyle)) - if !suffix.isEmpty { - var suffixStyle = senderStyle - suffixStyle.foregroundColor = baseColor.opacity(0.6) - result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) - } - result.append(AttributedString("> ").mergingAttributes(senderStyle)) - return result + messageFormatter.formatMessageHeader(message, colorScheme: colorScheme) } // MARK: - Noise Protocol Support @MainActor func updateEncryptionStatusForPeers() { - for peerID in connectedPeers { - updateEncryptionStatusForPeer(peerID) - } + peerIdentityCoordinator.updateEncryptionStatusForPeers() } @MainActor - private func updateEncryptionStatusForPeer(_ peerID: PeerID) { - let noiseService = meshService.getNoiseService() - - if noiseService.hasEstablishedSession(with: peerID) { - peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) - } else if noiseService.hasSession(with: peerID) { - // Session exists but not established - handshaking - peerEncryptionStatus[peerID] = .noiseHandshaking - } else { - // No session at all - peerEncryptionStatus[peerID] = Optional.none - } - - // Invalidate cache when encryption status changes - invalidateEncryptionCache(for: peerID) - - // UI will update automatically via @Published properties + func updateEncryptionStatus(for peerID: PeerID) { + peerIdentityCoordinator.updateEncryptionStatus(for: peerID) } @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - // Check cache first - if let cachedStatus = encryptionStatusCache[peerID] { - return cachedStatus - } - - // This must be a pure function - no state mutations allowed - // to avoid SwiftUI update loops - - // Check if we've ever established a session by looking for a fingerprint - let hasEverEstablishedSession = getFingerprint(for: peerID) != nil - - let sessionState = meshService.getNoiseSessionState(for: peerID) - - let status: EncryptionStatus - - // Determine status based on session state - switch sessionState { - case .established: - status = encryptionStatus(for: peerID) - case .handshaking, .handshakeQueued: - // If we've ever established a session, show secured instead of handshaking - if hasEverEstablishedSession { - // Check if it was verified before - status = encryptionStatus(for: peerID) - } else { - // First time establishing - show handshaking - status = .noiseHandshaking - } - case .none: - // If we've ever established a session, show secured instead of no handshake - if hasEverEstablishedSession { - // Check if it was verified before - status = encryptionStatus(for: peerID) - } else { - // Never established - show no handshake - status = .noHandshake - } - case .failed: - // If we've ever established a session, show secured instead of failed - if hasEverEstablishedSession { - // Check if it was verified before - status = encryptionStatus(for: peerID) - } else { - // Never established - show failed - status = .none - } - } - - // Cache the result - encryptionStatusCache[peerID] = status - - // Encryption status determined: \(status) - - return status + peerIdentityCoordinator.getEncryptionStatus(for: peerID) } // Clear caches when data changes - private func invalidateEncryptionCache(for peerID: PeerID? = nil) { - if let peerID { - encryptionStatusCache.removeValue(forKey: peerID) - } else { - encryptionStatusCache.removeAll() - } + @MainActor + func invalidateEncryptionCache(for peerID: PeerID? = nil) { + peerIdentityCoordinator.invalidateEncryptionCache(for: peerID) } // MARK: - Message Handling - + + @MainActor + func initializeConversationStore() { + publicConversationCoordinator.initializeConversationStore() + } + + @MainActor + func synchronizePublicConversationStore(for channel: ChannelID) { + publicConversationCoordinator.synchronizePublicConversationStore(for: channel) + } + + @MainActor + func synchronizePublicConversationStore(forGeohash geohash: String) { + publicConversationCoordinator.synchronizePublicConversationStore(forGeohash: geohash) + } + + @MainActor + func synchronizeAllPublicConversationStores() { + publicConversationCoordinator.synchronizeAllPublicConversationStores() + } + + @MainActor + func synchronizePrivateConversationStore() { + conversationStore.synchronizePrivateChats( + privateChatManager.privateChats, + unreadPeerIDs: privateChatManager.unreadMessages, + identityResolver: identityResolver + ) + } + + @MainActor + func synchronizeConversationSelectionStore() { + conversationStore.setSelectedPeerID( + privateChatManager.selectedPeer, + activeChannel: activeChannel, + identityResolver: identityResolver + ) + } + func trimMessagesIfNeeded() { if messages.count > maxMessages { messages = Array(messages.suffix(maxMessages)) @@ -2591,178 +1181,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @MainActor func refreshVisibleMessages(from channel: ChannelID? = nil) { - let target = channel ?? activeChannel - messages = timelineStore.messages(for: target) + publicConversationCoordinator.refreshVisibleMessages(from: channel) } @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { - if let spid = message.senderPeerID { - if spid.isGeoChat || spid.isGeoDM { - let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() - return getNostrPaletteColor(for: full, isDark: isDark) - } else if spid.id.count == 16 { - // Mesh short ID - return getPeerPaletteColor(for: spid, isDark: isDark) - } else { - return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) - } - } - // Fallback when we only have a display name - return Color(peerSeed: message.sender.lowercased(), isDark: isDark) + messageFormatter.senderColor(for: message, isDark: isDark) } // MARK: - MessageFormattingContext Protocol @MainActor func isSelfMessage(_ message: BitchatMessage) -> Bool { - if let spid = message.senderPeerID { - // In geohash channels, compare against our per-geohash nostr short ID - if case .location(let ch) = activeChannel, spid.isGeoChat { - let myGeo: NostrIdentity? = { - if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { - return cached.identity - } - // Derive and cache - if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - cachedGeohashIdentity = (ch.geohash, identity) - return identity - } - return nil - }() - if let myGeo { - return spid == PeerID(nostr: myGeo.publicKeyHex) - } - } - return spid == meshService.myPeerID - } - // Fallback by nickname - if message.sender == nickname { return true } - if message.sender.hasPrefix(nickname + "#") { return true } - return false + messageFormatter.isSelfMessage(message) } @MainActor func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { - return peerColor(for: message, isDark: isDark) + peerColor(for: message, isDark: isDark) } @MainActor func peerURL(for peerID: PeerID) -> URL? { - return URL(string: "bitchat://user/\(peerID.toPercentEncoded())") + messageFormatter.peerURL(for: peerID) } // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { - return getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) + messageFormatter.colorForNostrPubkey(pubkeyHexLowercased, isDark: isDark) } @MainActor func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { - return getPeerPaletteColor(for: peerID, isDark: isDark) - } - - // MARK: - Peer Palette Coordination - private let meshPalette = MinimalDistancePalette(config: .mesh) - private let nostrPalette = MinimalDistancePalette(config: .nostr) - - @MainActor - private func meshSeed(for peerID: PeerID) -> String { - if let full = getNoiseKeyForShortID(peerID)?.id.lowercased() { - return "noise:" + full - } - return peerID.id.lowercased() - } - - @MainActor - private func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { - if peerID == meshService.myPeerID { - return .orange - } - - meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) - if let color = meshPalette.color(for: peerID.id, isDark: isDark) { - return color - } - return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) - } - - @MainActor - private func currentMeshPaletteSeeds() -> [String: String] { - let myID = meshService.myPeerID - var seeds: [String: String] = [:] - for peer in allPeers where peer.peerID != myID { - seeds[peer.peerID.id] = meshSeed(for: peer.peerID) - } - return seeds - } - - @MainActor - private func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { - let myHex = currentGeohashIdentityHex() - if let myHex, pubkeyHexLowercased == myHex { - return .orange - } - - nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) - if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { - return color - } - return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) - } - - @MainActor - private func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { - var seeds: [String: String] = [:] - let excluded = myHex ?? "" - for person in visibleGeohashPeople() where person.id != excluded { - seeds[person.id] = "nostr:" + person.id - } - return seeds - } - - @MainActor - private func currentGeohashIdentityHex() -> String? { - if case .location(let channel) = LocationChannelManager.shared.selectedChannel, - let identity = try? idBridge.deriveIdentity(forGeohash: channel.geohash) { - return identity.publicKeyHex.lowercased() - } - return nil + messageFormatter.colorForMeshPeer(id: peerID, isDark: isDark) } // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { - // Clear messages from current timeline - messages.removeAll() - timelineStore.clear(channel: activeChannel) - - // Delete associated media files (images, voice notes, files) in background - // Only delete from current chat to avoid removing private chat media - Task.detached(priority: .utility) { - do { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let filesDir = base.appendingPathComponent("files", isDirectory: true) - - // Only clear public media (mesh channel only - geohash media is separate) - // Note: This is conservative - only clears outgoing since we authored those - let outgoingDirs = [ - filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), - filesDir.appendingPathComponent("images/outgoing", isDirectory: true), - filesDir.appendingPathComponent("files/outgoing", isDirectory: true) - ] - - for dir in outgoingDirs { - if FileManager.default.fileExists(atPath: dir.path) { - try? FileManager.default.removeItem(at: dir) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true, attributes: nil) - } - } - } catch { - SecureLogger.error("Failed to clear media files: \(error)", category: .session) - } - } + publicConversationCoordinator.clearCurrentPublicTimeline() } // MARK: - Message Management @@ -2774,31 +1232,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv trimMessagesIfNeeded() } - // Update encryption status in appropriate places, not during view updates - @MainActor - private func updateEncryptionStatus(for peerID: PeerID) { - let noiseService = meshService.getNoiseService() - - if noiseService.hasEstablishedSession(with: peerID) { - peerEncryptionStatus[peerID] = encryptionStatus(for: peerID) - } else if noiseService.hasSession(with: peerID) { - peerEncryptionStatus[peerID] = .noiseHandshaking - } else { - peerEncryptionStatus[peerID] = Optional.none - } - - // Invalidate cache when encryption status changes - invalidateEncryptionCache(for: peerID) - - // UI will update automatically via @Published properties - } - - // MARK: - Fingerprint Management - - func showFingerprint(for peerID: PeerID) { - showingFingerprintFor = peerID - } - // MARK: - Peer Lookup Helpers func getPeer(byID peerID: PeerID) -> BitchatPeer? { @@ -2807,167 +1240,37 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv @MainActor func getFingerprint(for peerID: PeerID) -> String? { - return unifiedPeerService.getFingerprint(for: peerID) - } - - /// Check if fingerprint is verified using our persisted data - @MainActor - private func encryptionStatus(for peerID: PeerID) -> EncryptionStatus { - if let fp = getFingerprint(for: peerID), verifiedFingerprints.contains(fp) { - return .noiseVerified - } else { - return .noiseSecured - } + peerIdentityCoordinator.getFingerprint(for: peerID) } /// Helper to resolve nickname for a peer ID through various sources @MainActor - private func resolveNickname(for peerID: PeerID) -> String { - // Guard against empty or very short peer IDs - guard !peerID.isEmpty else { - return "unknown" - } - - // Check if this might already be a nickname (not a hex peer ID) - // Peer IDs are hex strings, so they only contain 0-9 and a-f - if !peerID.isHex { - // If it's already a nickname, just return it - return peerID.id - } - - // First try direct peer nicknames from mesh service - let peerNicknames = meshService.getPeerNicknames() - if let nickname = peerNicknames[peerID] { - return nickname - } - - // Try to resolve through fingerprint and social identity - if let fingerprint = getFingerprint(for: peerID) { - if let identity = identityManager.getSocialIdentity(for: fingerprint) { - // Prefer local petname if set - if let petname = identity.localPetname { - return petname - } - // Otherwise use their claimed nickname - return identity.claimedNickname - } - } - - // Use anonymous with shortened peer ID - // Ensure we have at least 4 characters for the prefix - let prefixLength = min(4, peerID.id.count) - let prefix = String(peerID.id.prefix(prefixLength)) - - // Avoid "anonanon" by checking if ID already starts with "anon" - if prefix.starts(with: "anon") { - return "peer\(prefix)" - } - return "anon\(prefix)" + func resolveNickname(for peerID: PeerID) -> String { + peerIdentityCoordinator.resolveNickname(for: peerID) } + @MainActor func getMyFingerprint() -> String { - let fingerprint = meshService.getNoiseService().getIdentityFingerprint() - return fingerprint + peerIdentityCoordinator.getMyFingerprint() } @MainActor func verifyFingerprint(for peerID: PeerID) { - guard let fingerprint = getFingerprint(for: peerID) else { return } - - // Update secure storage with verified status - identityManager.setVerified(fingerprint: fingerprint, verified: true) - saveIdentityState() - - // Update local set for UI - verifiedFingerprints.insert(fingerprint) - - // Update encryption status after verification - updateEncryptionStatus(for: peerID) + verificationCoordinator.verifyFingerprint(for: peerID) } @MainActor func unverifyFingerprint(for peerID: PeerID) { - guard let fingerprint = getFingerprint(for: peerID) else { return } - identityManager.setVerified(fingerprint: fingerprint, verified: false) - saveIdentityState() - verifiedFingerprints.remove(fingerprint) - updateEncryptionStatus(for: peerID) + verificationCoordinator.unverifyFingerprint(for: peerID) } @MainActor func loadVerifiedFingerprints() { - // Load verified fingerprints directly from secure storage - verifiedFingerprints = identityManager.getVerifiedFingerprints() - // Log snapshot for debugging persistence - let sample = Array(verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)).map { $0.prefix(8) }.joined(separator: ", ") - SecureLogger.info("🔐 Verified loaded: \(verifiedFingerprints.count) [\(sample)]", category: .security) - // 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.peerID) - 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) - } - // Invalidate cached encryption statuses so offline favorites can show verified badges immediately - invalidateEncryptionCache() - // Trigger UI refresh of peer list - objectWillChange.send() + verificationCoordinator.loadVerifiedFingerprints() } - private func setupNoiseCallbacks() { - let noiseService = meshService.getNoiseService() - - // Set up authentication callback - noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in - DispatchQueue.main.async { - guard let self = self else { return } - - SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) - - // Update encryption status - if self.verifiedFingerprints.contains(fingerprint) { - self.peerEncryptionStatus[peerID] = .noiseVerified - // Encryption: noiseVerified - } else { - self.peerEncryptionStatus[peerID] = .noiseSecured - // Encryption: noiseSecured - } - - // Invalidate cache when encryption status changes - self.invalidateEncryptionCache(for: peerID) - - // Cache shortID -> full Noise key mapping as soon as session authenticates - if self.shortIDToNoiseKey[peerID] == nil, - let keyData = self.meshService.getNoiseService().getPeerPublicKeyData(peerID) { - let stable = PeerID(hexData: keyData) - self.shortIDToNoiseKey[peerID] = stable - SecureLogger.debug("🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stable.id.prefix(8))…", category: .session) - } - - // If a QR verification is pending but not sent yet, send it now that session is authenticated - if var pending = self.pendingQRVerifications[peerID], pending.sent == false { - self.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: pending.noiseKeyHex, nonceA: pending.nonceA) - pending.sent = true - self.pendingQRVerifications[peerID] = pending - SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security) - } - - // Schedule UI update - // UI will update automatically - } - } - - // Set up handshake required callback - noiseService.onHandshakeRequired = { [weak self] peerID in - DispatchQueue.main.async { - guard let self = self else { return } - self.peerEncryptionStatus[peerID] = .noiseHandshaking - - // Invalidate cache when encryption status changes - self.invalidateEncryptionCache(for: peerID) - } - } + func setupNoiseCallbacks() { + verificationCoordinator.setupNoiseCallbacks() } // MARK: - BitchatDelegate Methods @@ -2978,7 +1281,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// - Parameter command: The full command string including the leading slash /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help @MainActor - private func handleCommand(_ command: String) { + func handleCommand(_ command: String) { let result = commandProcessor.process(command) switch result { @@ -2997,230 +1300,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Message Reception func didReceiveMessage(_ message: BitchatMessage) { - Task { @MainActor in - // Early validation - guard !isMessageBlocked(message) else { return } - guard !message.content.trimmed.isEmpty || message.isPrivate else { return } - - // Route to appropriate handler - if message.isPrivate { - handlePrivateMessage(message) - } else { - handlePublicMessage(message) - } - - // Post-processing - checkForMentions(message) - sendHapticFeedback(for: message) - } - } - - /// Find message index trying both short (16-hex) and long (64-hex) peer ID formats. - /// Returns the peer ID where the message was found and its index, or nil if not found. - private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? { - // Try direct lookup first - if let messages = privateChats[peerID], - let idx = messages.firstIndex(where: { $0.id == messageID }) { - return (peerID, idx) - } - - // Try with full noise key if peerID is short (16 hex chars) - if peerID.bare.count == 16, - let peer = unifiedPeerService.getPeer(by: peerID), - !peer.noisePublicKey.isEmpty { - let longID = PeerID(hexData: peer.noisePublicKey) - if let messages = privateChats[longID], - let idx = messages.firstIndex(where: { $0.id == messageID }) { - return (longID, idx) - } - } - - // Try with short form if peerID is long (64 hex = noise key) - if peerID.bare.count == 64 { - let shortID = peerID.toShort() - if let messages = privateChats[shortID], - let idx = messages.firstIndex(where: { $0.id == messageID }) { - return (shortID, idx) - } - } - - return nil + transportEventCoordinator.didReceiveMessage(message) } // Low-level BLE events func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { - Task { @MainActor in - switch type { - case .privateMessage: - guard let pm = PrivateMessagePacket.decode(from: payload) else { return } - - // BCH-01-012: Check blocking before processing private message to prevent notification bypass - if isPeerBlocked(peerID) { - SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security) - return - } - - let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" - let pmMentions = parseMentions(from: pm.content) - let msg = BitchatMessage( - id: pm.messageID, - sender: senderName, - content: pm.content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: nickname, - senderPeerID: peerID, - mentions: pmMentions.isEmpty ? nil : pmMentions - ) - handlePrivateMessage(msg) - // Send delivery ACK back over BLE - meshService.sendDeliveryAck(for: pm.messageID, to: peerID) - - case .delivered: - guard let messageID = String(data: payload, encoding: .utf8) else { return } - guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, - let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } - - // Don't downgrade from .read to .delivered - if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return } - - privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date()) - objectWillChange.send() - - case .readReceipt: - guard let messageID = String(data: payload, encoding: .utf8) else { return } - guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, - let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } - - // Explicitly unwrap and re-assign to ensure the @Published setter is called - if let messages = privateChats[foundPeerID], idx < messages.count { - messages[idx].deliveryStatus = .read(by: name, at: Date()) - privateChats[foundPeerID] = messages - privateChatManager.objectWillChange.send() - objectWillChange.send() - } - case .verifyChallenge: - // Parse and respond - guard let tlv = VerificationService.shared.parseVerifyChallenge(payload) else { return } - // Ensure intended for our noise key - let myNoiseHex = meshService.getNoiseService().getStaticPublicKeyData().hexEncodedString().lowercased() - guard tlv.noiseKeyHex.lowercased() == myNoiseHex else { return } - // Deduplicate: ignore if we've already responded to this nonce for this peer - if let last = lastVerifyNonceByPeer[peerID], last == tlv.nonceA { return } - lastVerifyNonceByPeer[peerID] = tlv.nonceA - // Record inbound challenge time keyed by stable fingerprint if available - if let fp = getFingerprint(for: peerID) { - lastInboundVerifyChallengeAt[fp] = Date() - // If we've already verified this fingerprint locally, treat this as mutual and toast immediately (responder side) - if verifiedFingerprints.contains(fp) { - let now = Date() - let last = lastMutualToastAt[fp] ?? .distantPast - if now.timeIntervalSince(last) > 60 { // 1-minute throttle - lastMutualToastAt[fp] = now - let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) - NotificationService.shared.sendLocalNotification( - title: "Mutual verification", - body: "You and \(name) verified each other", - identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" - ) - } - } - } - meshService.sendVerifyResponse(to: peerID, noiseKeyHex: tlv.noiseKeyHex, nonceA: tlv.nonceA) - // Silent response: no toast needed on responder - case .verifyResponse: - guard let resp = VerificationService.shared.parseVerifyResponse(payload) else { return } - // Check pending for this peer - guard let pending = pendingQRVerifications[peerID] else { return } - guard resp.noiseKeyHex.lowercased() == pending.noiseKeyHex.lowercased(), resp.nonceA == pending.nonceA else { return } - // Verify signature with expected sign key - let ok = VerificationService.shared.verifyResponseSignature(noiseKeyHex: resp.noiseKeyHex, nonceA: resp.nonceA, signature: resp.signature, signerPublicKeyHex: pending.signKeyHex) - if ok { - pendingQRVerifications.removeValue(forKey: peerID) - if let fp = getFingerprint(for: peerID) { - let short = fp.prefix(8) - SecureLogger.info("🔐 Marking verified fingerprint: \(short)", category: .security) - identityManager.setVerified(fingerprint: fp, verified: true) - saveIdentityState() - verifiedFingerprints.insert(fp) - let name = unifiedPeerService.getPeer(by: peerID)?.nickname ?? resolveNickname(for: peerID) - NotificationService.shared.sendLocalNotification( - title: "Verified", - body: "You verified \(name)", - identifier: "verify-success-\(peerID)-\(UUID().uuidString)" - ) - // If we also recently responded to their challenge, flag mutual and toast (initiator side) - if let t = lastInboundVerifyChallengeAt[fp], Date().timeIntervalSince(t) < 600 { - let now = Date() - let lastToast = lastMutualToastAt[fp] ?? .distantPast - if now.timeIntervalSince(lastToast) > 60 { - lastMutualToastAt[fp] = now - NotificationService.shared.sendLocalNotification( - title: "Mutual verification", - body: "You and \(name) verified each other", - identifier: "verify-mutual-\(peerID)-\(UUID().uuidString)" - ) - } - } - updateEncryptionStatus(for: peerID) - } - } - } - } + transportEventCoordinator.didReceiveNoisePayload( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp + ) } func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { - Task { @MainActor in - let normalized = content.trimmed - let publicMentions = parseMentions(from: normalized) - let msg = BitchatMessage( - id: messageID, - sender: nickname, - content: normalized, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: peerID, - mentions: publicMentions.isEmpty ? nil : publicMentions - ) - handlePublicMessage(msg) - checkForMentions(msg) - sendHapticFeedback(for: msg) - } + transportEventCoordinator.didReceivePublicMessage( + from: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID + ) } // MARK: - QR Verification API @MainActor func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { - // Find a matching peer by Noise key - let targetNoise = qr.noiseKeyHex.lowercased() - guard let peer = unifiedPeerService.peers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { - return false - } - let peerID = peer.peerID - // If we already have a pending verification with this peer, don't send another - if pendingQRVerifications[peerID] != 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 - // If Noise session is established, send immediately; otherwise trigger handshake and send on auth - let noise = meshService.getNoiseService() - if noise.hasEstablishedSession(with: peerID) { - meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) - pending.sent = true - pendingQRVerifications[peerID] = pending - } else { - meshService.triggerHandshake(with: peerID) - } - return true + verificationCoordinator.beginQRVerification(with: qr) } // Mention parsing moved from BLE – use the existing non-optional helper below @@ -3235,325 +1341,24 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Peer Connection Events func didConnectToPeer(_ peerID: PeerID) { - SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) - - // Handle all main actor work async - Task { @MainActor in - isConnected = true - - // Register ephemeral session with identity manager - identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) - - // Intentionally do not resend favorites on reconnect. - // We only send our npub when a favorite is toggled on, or if our npub changes. - - // Force UI refresh - objectWillChange.send() - - // Cache mapping to full Noise key for session continuity on disconnect - if let peer = unifiedPeerService.getPeer(by: peerID) { - let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - shortIDToNoiseKey[peerID] = noiseKeyHex - } - - // Flush any queued messages for this peer via router - messageRouter.flushOutbox(for: peerID) - } + transportEventCoordinator.didConnectToPeer(peerID) } func didDisconnectFromPeer(_ peerID: PeerID) { - SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) - - // Remove ephemeral session from identity manager - identityManager.removeEphemeralSession(peerID: peerID) - - // If the open PM is tied to this short peer ID, switch UI context to the full Noise key (offline favorite) - var derivedStableKeyHex = shortIDToNoiseKey[peerID] - if derivedStableKeyHex == nil, - let key = meshService.getNoiseService().getPeerPublicKeyData(peerID) { - derivedStableKeyHex = PeerID(hexData: key) - shortIDToNoiseKey[peerID] = derivedStableKeyHex - } - - if let current = selectedPrivateChatPeer, current == peerID, let stableKeyHex = derivedStableKeyHex { - // Migrate messages view context to stable key so header shows favorite + Nostr globe - if let messages = privateChats[peerID] { - if privateChats[stableKeyHex] == nil { privateChats[stableKeyHex] = [] } - let existing = Set(privateChats[stableKeyHex]!.map { $0.id }) - for msg in messages where !existing.contains(msg.id) { - let updated = BitchatMessage( - id: msg.id, - sender: msg.sender, - content: msg.content, - timestamp: msg.timestamp, - isRelay: msg.isRelay, - originalSender: msg.originalSender, - isPrivate: msg.isPrivate, - recipientNickname: msg.recipientNickname, - senderPeerID: msg.senderPeerID == meshService.myPeerID ? meshService.myPeerID : stableKeyHex, - mentions: msg.mentions, - deliveryStatus: msg.deliveryStatus - ) - privateChats[stableKeyHex]?.append(updated) - } - privateChats[stableKeyHex]?.sort { $0.timestamp < $1.timestamp } - privateChats.removeValue(forKey: peerID) - } - if unreadPrivateMessages.contains(peerID) { - unreadPrivateMessages.remove(peerID) - unreadPrivateMessages.insert(stableKeyHex) - } - selectedPrivateChatPeer = stableKeyHex - objectWillChange.send() - } - - // Update peer list immediately and force UI refresh - DispatchQueue.main.async { [weak self] in - // UnifiedPeerService updates automatically via subscriptions - self?.objectWillChange.send() - } - - // Clear sent read receipts for this peer since they'll need to be resent after reconnection - // Only clear receipts for messages from this specific peer - if let messages = privateChats[peerID] { - for message in messages { - // Remove read receipts for messages FROM this peer (not TO this peer) - if message.senderPeerID == peerID { - sentReadReceipts.remove(message.id) - } - } - } + transportEventCoordinator.didDisconnectFromPeer(peerID) } func didUpdatePeerList(_ peers: [PeerID]) { - // UI updates must run on the main thread. - // The delegate callback is not guaranteed to be on the main thread. - DispatchQueue.main.async { - // Update through peer manager - // UnifiedPeerService updates automatically via subscriptions - self.isConnected = !peers.isEmpty - - // Clean up stale unread peer IDs whenever peer list updates - self.cleanupStaleUnreadPeerIDs() - - // Smart notification logic for "bitchatters nearby" - let meshPeers = peers.filter { peerID in - self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) - } - let meshPeerSet = Set(meshPeers) - - if meshPeerSet.isEmpty { - self.scheduleNetworkEmptyTimer() - } else { - self.invalidateNetworkEmptyTimer() - // Don't trim recentlySeenPeers here - let timers handle cleanup. - // Trimming immediately causes peers to be treated as "new" when they - // briefly drop and reconnect, triggering notification floods. - let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) - - if !newPeers.isEmpty { - // Rate limit: max one notification per 5 minutes - let cooldown = TransportConfig.networkNotificationCooldownSeconds - if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown { - // Only mark peers as seen when we actually notify about them - // This ensures peers arriving during cooldown will be included in the next notification - self.recentlySeenPeers.formUnion(newPeers) - self.lastNetworkNotificationTime = Date() - NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) - SecureLogger.info( - "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", - category: .session - ) - } - self.scheduleNetworkResetTimer() - } - } - - // Register ephemeral sessions for all connected peers - for peerID in peers { - self.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) - } - - // Schedule UI refresh to ensure offline favorites are shown - // UI will update automatically - - // Update encryption status for all peers - self.updateEncryptionStatusForPeers() - - // Schedule UI update for peer list change - // UI will update automatically - - // Check if we need to update private chat peer after reconnection - if self.selectedPrivateChatFingerprint != nil { - self.updatePrivateChatPeerIfNeeded() - } - - // Don't end private chat when peer temporarily disconnects - // The fingerprint tracking will allow us to reconnect when they come back - } + peerListCoordinator.didUpdatePeerList(peers) } - // MARK: - Helper Methods - - /// 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.peerID }) - let staleIDs = unreadPrivateMessages.subtracting(currentPeerIDs) - - if !staleIDs.isEmpty { - var idsToRemove: [PeerID] = [] - for staleID in staleIDs { - // Don't remove temporary Nostr peer IDs that have messages - if staleID.isGeoDM { - // Check if we have messages from this temporary peer - if let messages = privateChats[staleID], !messages.isEmpty { - // Keep this ID - it has messages - continue - } - } - - // Don't remove stable Noise key hexes (64 char hex strings) that have messages - // These are used for Nostr messages when peer is offline - if staleID.isNoiseKeyHex { - if let messages = privateChats[staleID], !messages.isEmpty { - // Keep this ID - it's a stable key with messages - continue - } - } - - // Remove this stale ID - idsToRemove.append(staleID) - unreadPrivateMessages.remove(staleID) - } - - if !idsToRemove.isEmpty { - SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) - } - } - - // Also clean up old sentReadReceipts to prevent unlimited growth - // Keep only receipts from messages we still have - cleanupOldReadReceipts() - } - - @MainActor - private func scheduleNetworkResetTimer() { - networkResetTimer?.invalidate() - networkResetTimer = Timer.scheduledTimer( - timeInterval: networkResetGraceSeconds, - target: self, - selector: #selector(onNetworkResetTimerFired(_:)), - userInfo: nil, - repeats: false - ) - } - - @MainActor - @objc private func onNetworkResetTimerFired(_ timer: Timer) { - let activeMeshPeers = meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) - } - if activeMeshPeers.isEmpty { - recentlySeenPeers.removeAll() - SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session) - } else { - SecureLogger.debug("⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", category: .session) - } - networkResetTimer = nil - } - - @MainActor - private func scheduleNetworkEmptyTimer() { - guard networkEmptyTimer == nil else { return } - networkEmptyTimer = Timer.scheduledTimer( - timeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds, - target: self, - selector: #selector(onNetworkEmptyTimerFired(_:)), - userInfo: nil, - repeats: false - ) - SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session) - } - - @MainActor - private func invalidateNetworkEmptyTimer() { - if networkEmptyTimer != nil { - networkEmptyTimer?.invalidate() - networkEmptyTimer = nil - } - } - - @MainActor - @objc private func onNetworkEmptyTimerFired(_ timer: Timer) { - let activeMeshPeers = meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) - } - if activeMeshPeers.isEmpty { - recentlySeenPeers.removeAll() - SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session) - } else { - SecureLogger.debug("⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", category: .session) - } - networkEmptyTimer = nil - } - - private func cleanupOldReadReceipts() { - // Skip cleanup during startup phase or if privateChats is empty - // This prevents removing valid receipts before messages are loaded - if isStartupPhase || privateChats.isEmpty { - return - } - - // Build set of all message IDs we still have - var validMessageIDs = Set() - for (_, messages) in privateChats { - for message in messages { - validMessageIDs.insert(message.id) - } - } - - // Remove receipts for messages we no longer have - let oldCount = sentReadReceipts.count - sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) - - let removedCount = oldCount - sentReadReceipts.count - if removedCount > 0 { - SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) - } + func cleanupOldReadReceipts() { + deliveryCoordinator.cleanupOldReadReceipts() } func parseMentions(from content: String) -> [String] { - // Allow optional disambiguation suffix '#abcd' for duplicate nicknames - let regex = Patterns.mention - let nsContent = content as NSString - let nsLen = nsContent.length - let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) - - var mentions: [String] = [] - let peerNicknames = meshService.getPeerNicknames() - // Compose the valid mention tokens based on current peers (already suffixed where needed) - var validTokens = Set(peerNicknames.values) - // Always allow mentioning self by base nickname and suffixed disambiguator - validTokens.insert(nickname) - let selfSuffixToken = nickname + "#" + String(meshService.myPeerID.id.prefix(4)) - validTokens.insert(selfSuffixToken) - - for match in matches { - if let range = Range(match.range(at: 1), in: content) { - let mentionedName = String(content[range]) - // Only include if it's a current valid token (base or suffixed) - if validTokens.contains(mentionedName) { - mentions.append(mentionedName) - } - } - } - - return Array(Set(mentions)) // Remove duplicates + composerCoordinator.parseMentions(from: content) } func isFavorite(fingerprint: String) -> Bool { @@ -3563,145 +1368,52 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // MARK: - Delivery Tracking func didReceiveReadReceipt(_ receipt: ReadReceipt) { - // Find the message and update its read status - updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp)) + performDeliveryUpdate { coordinator in + coordinator.didReceiveReadReceipt(receipt) + } } func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - updateMessageDeliveryStatus(messageID, status: status) + performDeliveryUpdate { coordinator in + coordinator.didUpdateMessageDeliveryStatus(messageID, status: status) + } } func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { - - // Helper function to check if we should skip this update - func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { - guard let current = currentStatus else { return false } - - // Don't downgrade from read to delivered - switch (current, newStatus) { - case (.read, .delivered): - return true - case (.read, .sent): - return true - default: - return false - } + performDeliveryUpdate { coordinator in + coordinator.updateMessageDeliveryStatus(messageID, status: status) } - - // Update in main messages - if let index = messages.firstIndex(where: { $0.id == messageID }) { - let currentStatus = messages[index].deliveryStatus - if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - messages[index].deliveryStatus = status - } - } - - // Update in private chats - for (peerID, chatMessages) in privateChats { - guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } - - let currentStatus = chatMessages[index].deliveryStatus - guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } - - // Update delivery status directly (BitchatMessage is a class/reference type) - privateChats[peerID]?[index].deliveryStatus = status - } - - // Trigger UI update for delivery status change - DispatchQueue.main.async { [weak self] in - self?.objectWillChange.send() - } - } // MARK: - Helper for System Messages func addSystemMessage(_ content: String, timestamp: Date = Date()) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: timestamp, - isRelay: false - ) - messages.append(systemMessage) + publicConversationCoordinator.addSystemMessage(content, timestamp: timestamp) } /// Add a system message to the mesh timeline only (never geohash). /// If mesh is currently active, also append to the visible `messages`. @MainActor func addMeshOnlySystemMessage(_ content: String) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: Date(), - isRelay: false - ) - timelineStore.append(systemMessage, to: .mesh) - refreshVisibleMessages() - trimMessagesIfNeeded() - objectWillChange.send() + publicConversationCoordinator.addMeshOnlySystemMessage(content) } /// Public helper to add a system message to the public chat timeline. /// Also persists the message into the active channel's backing store so it survives timeline rebinds. @MainActor func addPublicSystemMessage(_ content: String) { - let systemMessage = BitchatMessage( - sender: "system", - content: content, - timestamp: Date(), - isRelay: false - ) - timelineStore.append(systemMessage, to: activeChannel) - refreshVisibleMessages(from: activeChannel) - // Track the content key so relayed copies of the same system-style message are ignored - let contentKey = deduplicationService.normalizedContentKey(systemMessage.content) - deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) - trimMessagesIfNeeded() - objectWillChange.send() + publicConversationCoordinator.addPublicSystemMessage(content) } /// Add a system message only if viewing a geohash location channel (never post to mesh). @MainActor func addGeohashOnlySystemMessage(_ content: String) { - if case .location = activeChannel { - addPublicSystemMessage(content) - } else { - // Not on a location channel yet: queue to show when user switches - timelineStore.queueGeohashSystemMessage(content) - } + publicConversationCoordinator.addGeohashOnlySystemMessage(content) } // Send a public message without adding a local user echo. // Used for emotes where we want a local system-style confirmation instead. @MainActor func sendPublicRaw(_ content: String) { - if case .location(let ch) = activeChannel { - Task { @MainActor in - do { - let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash) - let event = try NostrProtocol.createEphemeralGeohashEvent( - content: content, - geohash: ch.geohash, - senderIdentity: identity, - nickname: self.nickname, - teleported: LocationChannelManager.shared.teleported - ) - let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) - if targetRelays.isEmpty { - NostrRelayManager.shared.sendEvent(event) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - } catch { - SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session) - } - } - return - } - // Default: send over mesh - meshService.sendMessage(content, - mentions: [], - messageID: UUID().uuidString, - timestamp: Date()) + publicConversationCoordinator.sendPublicRaw(content) } @@ -3735,151 +1447,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Handle incoming public message @MainActor func handlePublicMessage(_ message: BitchatMessage) { - let finalMessage = processActionMessage(message) - - // Drop if sender is blocked (covers geohash via Nostr pubkey mapping) - if isMessageBlocked(finalMessage) { return } - - // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) - let isGeo = finalMessage.senderPeerID?.isGeoChat == true - - // Apply per-sender and per-content rate limits (drop if exceeded) - // Treat action-style system messages (which carry a senderPeerID) the same as regular user messages - let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil - if shouldRateLimit { - let senderKey = normalizedSenderKey(for: finalMessage) - let contentKey = deduplicationService.normalizedContentKey(finalMessage.content) - if !publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { return } - } - - // Size cap: drop extremely large public messages early - if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } - - // Persist mesh messages to mesh timeline always - if !isGeo && finalMessage.sender != "system" { - timelineStore.append(finalMessage, to: .mesh) - } - - // Persist geochat messages to per-geohash timeline - if isGeo && finalMessage.sender != "system" { - if let gh = currentGeohash { - _ = timelineStore.appendIfAbsent(finalMessage, toGeohash: gh) - } - } - - // Only add message to current timeline if it matches active channel or is system - let isSystem = finalMessage.sender == "system" - let channelMatches: Bool = { - switch activeChannel { - case .mesh: return !isGeo || isSystem - case .location: return isGeo || isSystem - } - }() - - guard channelMatches else { return } - - // Removed background nudge notification for generic "new chats!" - - // Append via batching buffer (skip empty content) with simple dedup by ID - if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) { - publicMessagePipeline.enqueue(finalMessage) - } + publicConversationCoordinator.handlePublicMessage(message) } - /// Check for mentions and send notifications - - func checkForMentions(_ message: BitchatMessage) { // Determine our acceptable mention token. If any connected peer shares our nickname, - // require the disambiguated form '#' to trigger. - var myTokens: Set = [nickname] - let meshPeers = meshService.getPeerNicknames() - let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + "#") } - if !collisions.isEmpty { - let suffix = "#" + String(meshService.myPeerID.id.prefix(4)) - myTokens = [nickname + suffix] + /// Check for mentions and send notifications + func checkForMentions(_ message: BitchatMessage) { + publicConversationCoordinator.checkForMentions(message) } - let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false) - - if isMentioned && message.sender != nickname { - SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) - NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) - } -} /// Send haptic feedback for special messages (iOS only) - func sendHapticFeedback(for message: BitchatMessage) { #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - - // Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd' - var tokens: [String] = [nickname] - #if os(iOS) - switch activeChannel { - case .location(let ch): - if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let d = String(id.publicKeyHex.suffix(4)) - tokens.append(nickname + "#" + d) - } - case .mesh: - break - } - #endif - - let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") - let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") - - let isHugForMe = message.content.contains("🫂") && hugsMe - let isSlapForMe = message.content.contains("🐟") && slapsMe - - if isHugForMe && message.sender != nickname { - // Long warm haptic for hugs - let impactFeedback = UIImpactFeedbackGenerator(style: .medium) - impactFeedback.prepare() - - for i in 0..<8 { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds) { - impactFeedback.impactOccurred() - } - } - } else if isSlapForMe && message.sender != nickname { - // Sharp haptic for slaps - let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) - impactFeedback.prepare() - impactFeedback.impactOccurred() - } - #endif + func sendHapticFeedback(for message: BitchatMessage) { + publicConversationCoordinator.sendHapticFeedback(for: message) } } // End of ChatViewModel class - -extension ChatViewModel: PublicMessagePipelineDelegate { - func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { - messages - } - - func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { - self.messages = messages - } - - func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { - deduplicationService.normalizedContentKey(content) - } - - func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { - deduplicationService.contentTimestamp(forKey: key) - } - - func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { - deduplicationService.recordContentKey(key, timestamp: timestamp) - } - - func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { - trimMessagesIfNeeded() - } - - func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { - _ = formatMessageAsText(message, colorScheme: currentColorScheme) - } - - func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { - isBatchingPublic = isBatching - } -} diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift new file mode 100644 index 00000000..b876ba08 --- /dev/null +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -0,0 +1,279 @@ +import BitFoundation +import BitLogger +import Combine +import Foundation + +struct ChatViewModelServiceBundle { + let commandProcessor: CommandProcessor + let messageRouter: MessageRouter + let privateChatManager: PrivateChatManager + let unifiedPeerService: UnifiedPeerService + let autocompleteService: AutocompleteService + let deduplicationService: MessageDeduplicationService + let publicMessagePipeline: PublicMessagePipeline + + @MainActor + init( + keychain: KeychainManagerProtocol, + idBridge: NostrIdentityBridge, + identityManager: SecureIdentityStateManagerProtocol, + meshService: Transport + ) { + let commandProcessor = CommandProcessor(identityManager: identityManager) + let privateChatManager = PrivateChatManager(meshService: meshService) + let unifiedPeerService = UnifiedPeerService( + meshService: meshService, + idBridge: idBridge, + identityManager: identityManager + ) + let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) + nostrTransport.senderPeerID = meshService.myPeerID + let messageRouter = MessageRouter(transports: [meshService, nostrTransport]) + + self.commandProcessor = commandProcessor + self.messageRouter = messageRouter + self.privateChatManager = privateChatManager + self.unifiedPeerService = unifiedPeerService + self.autocompleteService = AutocompleteService() + self.deduplicationService = MessageDeduplicationService() + self.publicMessagePipeline = PublicMessagePipeline() + } +} + +@MainActor +final class ChatViewModelBootstrapper { + private unowned let viewModel: ChatViewModel + + init(viewModel: ChatViewModel) { + self.viewModel = viewModel + } + + static func loadPersistedReadReceipts(userDefaults: UserDefaults = .standard) -> Set { + guard let data = userDefaults.data(forKey: "sentReadReceipts"), + let receipts = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return Set(receipts) + } + + func configure() { + wireServiceGraph() + bindFeatureObjectChanges() + loadPersistedViewState() + configureTransport() + startRuntimeServices() + bindPeerService() + configureNoiseCallbacks() + bindTransferProgress() + configureGeoChannels() + bindTeleportState() + requestNotifications() + registerObservers() + } +} + +private extension ChatViewModelBootstrapper { + func wireServiceGraph() { + viewModel.privateChatManager.messageRouter = viewModel.messageRouter + viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService + viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter + viewModel.commandProcessor.contextProvider = viewModel + viewModel.commandProcessor.meshService = viewModel.meshService + viewModel.participantTracker.configure(context: viewModel) + } + + func bindFeatureObjectChanges() { + viewModel.privateChatManager.objectWillChange + .sink { [weak viewModel] _ in + viewModel?.objectWillChange.send() + } + .store(in: &viewModel.cancellables) + + viewModel.privateChatManager.$privateChats + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] _ in + Task { @MainActor [weak viewModel] in + viewModel?.synchronizePrivateConversationStore() + } + } + .store(in: &viewModel.cancellables) + + viewModel.privateChatManager.$unreadMessages + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] _ in + Task { @MainActor [weak viewModel] in + viewModel?.synchronizePrivateConversationStore() + } + } + .store(in: &viewModel.cancellables) + + viewModel.privateChatManager.$selectedPeer + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] _ in + Task { @MainActor [weak viewModel] in + viewModel?.synchronizeConversationSelectionStore() + } + } + .store(in: &viewModel.cancellables) + + viewModel.participantTracker.objectWillChange + .sink { [weak viewModel] _ in + viewModel?.objectWillChange.send() + } + .store(in: &viewModel.cancellables) + } + + func loadPersistedViewState() { + viewModel.loadNickname() + viewModel.loadVerifiedFingerprints() + } + + func configureTransport() { + viewModel.meshService.delegate = viewModel + + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in + guard let viewModel else { return } + _ = viewModel.getMyFingerprint() + } + + viewModel.meshService.setNickname(viewModel.nickname) + } + + func startRuntimeServices() { + viewModel.meshService.startServices() + + viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator + viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in + guard let viewModel, + let bleService = viewModel.meshService as? BLEService else { return } + let state = bleService.getCurrentBluetoothState() + viewModel.updateBluetoothState(state) + } + + viewModel.nostrRelayManager = NostrRelayManager.shared + viewModel.messageRouter.flushAllOutbox() + + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + try? await Task.sleep( + nanoseconds: UInt64(TransportConfig.uiStartupPhaseDurationSeconds * 1_000_000_000) + ) + viewModel.isStartupPhase = false + } + } + + func bindPeerService() { + viewModel.unifiedPeerService.$peers + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] peers in + Task { @MainActor [weak viewModel] in + guard let viewModel else { return } + + viewModel.allPeers = peers + viewModel.identityResolver.register(peers: peers) + + var uniquePeers: [PeerID: BitchatPeer] = [:] + for peer in peers { + if uniquePeers[peer.peerID] == nil { + uniquePeers[peer.peerID] = peer + } else { + SecureLogger.warning( + "⚠️ Duplicate peer ID detected: \(peer.peerID) (\(peer.displayName))", + category: .session + ) + } + } + viewModel.peerIndex = uniquePeers + + if viewModel.hasTrackedPrivateChatSelection { + viewModel.updatePrivateChatPeerIfNeeded() + } + + viewModel.synchronizePrivateConversationStore() + viewModel.synchronizeConversationSelectionStore() + } + } + .store(in: &viewModel.cancellables) + } + + func configureNoiseCallbacks() { + viewModel.setupNoiseCallbacks() + } + + func bindTransferProgress() { + TransferProgressManager.shared.publisher + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] event in + Task { @MainActor [weak viewModel] in + viewModel?.handleTransferEvent(event) + } + } + .store(in: &viewModel.cancellables) + } + + func configureGeoChannels() { + viewModel.geoChannelCoordinator = GeoChannelCoordinator( + locationManager: viewModel.locationManager, + onChannelSwitch: { [weak viewModel] channel in + viewModel?.switchLocationChannel(to: channel) + }, + beginSampling: { [weak viewModel] geohashes in + viewModel?.beginGeohashSampling(for: geohashes) + }, + endSampling: { [weak viewModel] in + viewModel?.endGeohashSampling() + } + ) + } + + func bindTeleportState() { + viewModel.locationManager.$teleported + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] isTeleported in + guard let viewModel else { return } + Task { @MainActor [weak viewModel] in + guard let viewModel, + case .location(let channel) = viewModel.activeChannel, + let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + else { + return + } + + let key = identity.publicKeyHex.lowercased() + let hasRegional = !viewModel.locationManager.availableChannels.isEmpty + let inRegional = viewModel.locationManager.availableChannels.contains { + $0.geohash == channel.geohash + } + + if isTeleported && hasRegional && !inRegional { + viewModel.locationPresenceStore.markTeleported(key) + } else { + viewModel.locationPresenceStore.clearTeleported(key) + } + } + } + .store(in: &viewModel.cancellables) + } + + func requestNotifications() { + NotificationService.shared.requestAuthorization() + } + + func registerObservers() { + NotificationCenter.default.addObserver( + viewModel, + selector: #selector(ChatViewModel.handleFavoriteStatusChanged(_:)), + name: .favoriteStatusChanged, + object: nil + ) + + NotificationCenter.default.addObserver( + viewModel, + selector: #selector(ChatViewModel.handlePeerStatusUpdate(_:)), + name: Notification.Name("peerStatusUpdated"), + object: nil + ) + } +} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index b36aa49c..fff0897e 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -5,846 +5,137 @@ // Geohash and Nostr logic for ChatViewModel // -import Foundation -import Combine -import BitLogger import BitFoundation -import SwiftUI -import Tor +import Foundation extension ChatViewModel { - // MARK: - Geohash Subscription - - // Resubscribe to the active geohash channel without clearing timeline @MainActor func resubscribeCurrentGeohash() { - guard case .location(let ch) = activeChannel else { return } - guard let subID = geoSubscriptionID else { - // No existing subscription; set it up - switchLocationChannel(to: activeChannel) - return - } - // Ensure participant decay timer is running - participantTracker.startRefreshTimer() - // Unsubscribe + resubscribe - NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral( - ch.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: ch.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - self?.subscribeNostrEvent(event) - } - // Resubscribe geohash DMs for this identity - if let dmSub = geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil - } - - if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - let dmSub = "geo-dm-\(ch.geohash)" - geoDmSubscriptionID = dmSub - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - self?.subscribeGiftWrap(giftWrap, id: id) - } - } + nostrCoordinator.resubscribeCurrentGeohash() } - + + @MainActor func subscribeNostrEvent(_ event: NostrEvent) { - guard event.isValidSignature() else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || - event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !deduplicationService.hasProcessedNostrEvent(event.id) - else { - return - } - - deduplicationService.recordNostrEvent(event.id) - - if let gh = currentGeohash, - let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh), - myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { - // Skip very recent self-echo from relay, but allow older events (e.g., after app restart) - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmed - geoNicknames[event.pubkey.lowercased()] = nick - } - - // Store mapping for geohash sender IDs used in messages (ensures consistent colors) - nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey - - // Update participants last-seen for this pubkey - participantTracker.recordParticipant(pubkeyHex: event.pubkey) - - // If presence heartbeat (Kind 20001), stop here - no content to display - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - // Track teleported tag (only our format ["t","teleport"]) for icon state - let hasTeleportTag = event.tags.contains(where: { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - }) - - if hasTeleportTag { - let key = event.pubkey.lowercased() - // Do not mark our own key from historical events; rely on manager.teleported for self - let isSelf: Bool = { - if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { - return my.publicKeyHex.lowercased() == key - } - return false - }() - if !isSelf { - Task { @MainActor in - teleportedGeo = teleportedGeo.union([key]) - } - } - } - - let senderName = displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmed - - // Clamp future timestamps to now to avoid future-dated messages skewing order - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let timestamp = min(rawTs, Date()) - let mentions = parseMentions(from: content) - let msg = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - Task { @MainActor in - // BCH-01-012: Check blocking before any notifications - // handlePublicMessage has its own blocking check but returns silently, - // so we must also guard checkForMentions to prevent notification bypass - let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - - handlePublicMessage(msg) - - // Only check mentions and send haptic if sender is not blocked - if !isBlocked { - checkForMentions(msg) - sendHapticFeedback(for: msg) - } - } + nostrCoordinator.subscribeNostrEvent(event) } + @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard giftWrap.isValidSignature() else { return } - guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } - deduplicationService.recordNostrEvent(giftWrap.id) - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), - let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let noisePayload = NoisePayload.decode(packet.payload) - else { - return - } - - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - let convKey = PeerID(nostr_: senderPubkey) - nostrKeyMapping[convKey] = senderPubkey - - switch noisePayload.type { - case .privateMessage: - handlePrivateMessage(noisePayload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) - case .delivered: - handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: - // QR verification payloads over Nostr are not supported; ignore in geohash DMs - break - } + nostrCoordinator.subscribeGiftWrap(giftWrap, id: id) } - // MARK: - Geohash Channel Handling - @MainActor func switchLocationChannel(to channel: ChannelID) { - // Reset pending public batches to avoid cross-channel bleed - publicMessagePipeline.reset() - - activeChannel = channel - publicMessagePipeline.updateActiveChannel(channel) - - // Reset deduplication set and optionally hydrate timeline for mesh - deduplicationService.clearNostrCaches() - switch channel { - case .mesh: - refreshVisibleMessages(from: .mesh) - // Debug: log if any empty messages are present - let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count - if emptyMesh > 0 { - SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) - } - participantTracker.stopRefreshTimer() - participantTracker.setActiveGeohash(nil) - teleportedGeo.removeAll() - case .location: - refreshVisibleMessages(from: channel) - } - // If switching to a location channel, flush any pending geohash-only system messages - if case .location = channel { - for content in timelineStore.drainPendingGeohashSystemMessages() { - addPublicSystemMessage(content) - } - } - // Unsubscribe previous - if let sub = geoSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) - geoSubscriptionID = nil - } - if let dmSub = geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - geoDmSubscriptionID = nil - } - currentGeohash = nil - participantTracker.setActiveGeohash(nil) - // Reset nickname cache for geochat participants - geoNicknames.removeAll() - - guard case .location(let ch) = channel else { return } - currentGeohash = ch.geohash - participantTracker.setActiveGeohash(ch.geohash) - - // Ensure self appears immediately in the people list; mark teleported state only when truly teleported - if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex) - let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } - let key = id.publicKeyHex.lowercased() - if LocationChannelManager.shared.teleported && hasRegional && !inRegional { - teleportedGeo = teleportedGeo.union([key]) - SecureLogger.info("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) - } else { - teleportedGeo.remove(key) - } - } - - let subID = "geo-\(ch.geohash)" - geoSubscriptionID = subID - participantTracker.startRefreshTimer() - let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) - let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - self?.handleNostrEvent(event) - } - - subscribeToGeoChat(ch) + nostrCoordinator.switchLocationChannel(to: channel) } - + + @MainActor func handleNostrEvent(_ event: NostrEvent) { - guard event.isValidSignature() else { return } - // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || - event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } - - // Deduplicate - if deduplicationService.hasProcessedNostrEvent(event.id) { return } - deduplicationService.recordNostrEvent(event.id) - - // Log incoming tags for diagnostics - let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") - SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) - - // If this pubkey is blocked, skip mapping, participants, and timeline - if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { - return - } - - // Track teleport tag for participants – only our format ["t", "teleport"] - let hasTeleportTag: Bool = event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - - let isSelf: Bool = { - if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) { - return my.publicKeyHex.lowercased() == event.pubkey.lowercased() - } - return false - }() - - if hasTeleportTag { - // Avoid marking our own key from historical events; rely on manager.teleported for self - if !isSelf { - let key = event.pubkey.lowercased() - Task { @MainActor in - teleportedGeo = teleportedGeo.union([key]) - SecureLogger.info("GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) - } - } - } - - // Update participants last-seen for this pubkey - participantTracker.recordParticipant(pubkeyHex: event.pubkey) - - // Skip only very recent self-echo from relay; include older self events for hydration - if isSelf { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - // Cache nickname from tag if present - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed - } - - // Store mapping for geohash DM initiation - nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey - - // If presence heartbeat (Kind 20001), stop here - no content to display - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let senderName = displayNameForNostrPubkey(event.pubkey) - let content = event.content - - // If this is a teleport presence event (no content), don't add to timeline - if let teleTag = event.tags.first(where: { $0.first == "t" }), - teleTag.count >= 2, - teleTag[1] == "teleport", - content.trimmed.isEmpty { - return - } - - // Clamp future timestamps - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let mentions = parseMentions(from: content) - let msg = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: min(rawTs, Date()), - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor in - handlePublicMessage(msg) - checkForMentions(msg) - sendHapticFeedback(for: msg) - } + nostrCoordinator.handleNostrEvent(event) } - + @MainActor func subscribeToGeoChat(_ ch: GeohashChannel) { - guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return } - - let dmSub = "geo-dm-\(ch.geohash)" - geoDmSubscriptionID = dmSub - // pared back logging: subscribe debug only - // Log GeoDM subscribe only when Tor is ready to avoid early noise - if TorManager.shared.isReady { - SecureLogger.debug("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) - } - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - self?.handleGiftWrap(giftWrap, id: id) - } + nostrCoordinator.subscribeToGeoChat(ch) } - + + @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard giftWrap.isValidSignature() else { return } - if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { - return - } - deduplicationService.recordNostrEvent(giftWrap.id) - - // Decrypt with per-geohash identity - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { - SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session) - return - } - - SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) - - guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) - else { - return - } - - let convKey = PeerID(nostr_: senderPubkey) - nostrKeyMapping[convKey] = senderPubkey - - switch payload.type { - case .privateMessage: - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: id, messageTimestamp: messageTimestamp) - case .delivered: - handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - - // Explicitly list other cases so we get compile-time check if a new case is added in the future - case .verifyChallenge, .verifyResponse: - break - } + nostrCoordinator.handleGiftWrap(giftWrap, id: id) } @MainActor func sendGeohash(context: GeoOutgoingContext) { - let ch = context.channel - let event = context.event - let identity = context.identity - - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: ch.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(ch.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - // Track ourselves as active participant - participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) - nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex - SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) - - // If we tagged this as teleported, also mark our pubkey in teleportedGeo for UI - // Only when not in our regional set (and regional list is known) - let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } - - if context.teleported && hasRegional && !inRegional { - let key = identity.publicKeyHex.lowercased() - teleportedGeo = teleportedGeo.union([key]) - SecureLogger.info("GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: .session) - } - - deduplicationService.recordNostrEvent(event.id) + nostrCoordinator.sendGeohash(context: context) } - // MARK: - Sampling - - /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel. @MainActor func beginGeohashSampling(for geohashes: [String]) { - // Disable sampling when app is backgrounded (Tor is stopped there) - if !TorManager.shared.isForeground() { - endGeohashSampling() - return - } - // Determine which to add and which to remove - let desired = Set(geohashes) - let current = Set(geoSamplingSubs.values) - let toAdd = desired.subtracting(current) - let toRemove = current.subtracting(desired) - - for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { - NostrRelayManager.shared.unsubscribe(id: subID) - geoSamplingSubs.removeValue(forKey: subID) - } - - for gh in toAdd { - subscribe(gh) - } + nostrCoordinator.beginGeohashSampling(for: geohashes) } - + @MainActor func subscribe(_ gh: String) { - let subID = "geo-sample-\(gh)" - geoSamplingSubs[subID] = gh - let filter = NostrFilter.geohashEphemeral( - gh, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), - limit: TransportConfig.nostrGeohashSampleLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - self?.subscribeNostrEvent(event, gh: gh) - } + nostrCoordinator.subscribe(gh) } - + + @MainActor func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard event.isValidSignature() else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || - event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } - - // Compute current participant count (5-minute window) BEFORE updating with this event - let existingCount = participantTracker.participantCount(for: gh) - - // Update participants for this specific geohash - participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) - - // Notify only on rising-edge: previously zero people, now someone sends a chat - guard let content = event.content.trimmedOrNilIfEmpty else { return } - - // Respect geohash blocks - if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - - // Skip self identity for this geohash - if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } - - // Only trigger when there were zero participants in this geohash recently - guard existingCount == 0 else { return } - - // Avoid notifications for old sampled events when launching or (re)subscribing - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) > 30 { return } - - // Foreground-only notifications: app must be active, and not already viewing this geohash - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - if case .location(let ch) = activeChannel, ch.geohash == gh { return } - #elseif os(macOS) - guard NSApplication.shared.isActive else { return } - if case .location(let ch) = activeChannel, ch.geohash == gh { return } - #endif - - cooldownPerGeohash(gh, content: content, event: event) + nostrCoordinator.subscribeNostrEvent(event, gh: gh) } - + + @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - let now = Date() - let last = lastGeoNotificationAt[gh] ?? .distantPast - if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } - - // Compose a short preview - let preview: String = { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - if content.count <= maxLen { return content } - let idx = content.index(content.startIndex, offsetBy: maxLen) - return String(content[.. Data? { - // Check favorites for this Nostr key - let favorites = FavoritesPersistenceService.shared.favorites.values - var npubToMatch = nostrPubkey - - // Convert hex to npub if needed for comparison - if !nostrPubkey.hasPrefix("npub") { - if let pubkeyData = Data(hexString: nostrPubkey), - let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { - npubToMatch = encoded - } else { - SecureLogger.warning("⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", category: .session) - } - } - - for relationship in favorites { - // Search through favorites for matching Nostr pubkey - if let storedNostrKey = relationship.peerNostrPublicKey { - // Compare against stored key (could be hex or npub) - if storedNostrKey == npubToMatch { - // SecureLogger.debug("✅ Found Noise key for Nostr sender (npub match)", category: .session) - return relationship.peerNoisePublicKey - } - - // Also try comparing raw hex if stored key is hex - if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { - SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session) - return relationship.peerNoisePublicKey - } - } - } - - SecureLogger.debug("⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", category: .session) - return nil + nostrCoordinator.findNoiseKey(for: nostrPubkey) } - func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { - // If we have a Noise key, try to route securely if possible, otherwise fallback to direct - if let _ = key { - // Ideally we would use MessageRouter here, but for simplicity in this direct callback: - // check if we have an identity - if let id = try? idBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) - } - } else if let id = try? idBridge.getCurrentNostrIdentity() { - // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id) - SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) - } - - // Same for READ receipt if viewing - if !wasReadBefore && selectedPrivateChatPeer == message.senderPeerID { - if let _ = key { - if let id = try? idBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) - } - } else if let id = try? idBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) - SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) - } - } - } - - func handleFavoriteNotification(content: String, from nostrPubkey: String) { - // Try to find Noise key associated with this Nostr pubkey - guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } - - let isFavorite = content.contains("FAVORITE:TRUE") - let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" - - // Update favorite status - if isFavorite { - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: nostrPubkey, - peerNickname: senderNickname - ) - } else { - // Only remove if we don't have it set locally - // Logic handled by persistence service usually, here we just update remote state - // Actually for now we just process the notification - } - - // Extract Nostr public key if included - var extractedNostrPubkey: String? = nil - if let range = content.range(of: "NPUB:") { - let suffix = content[range.upperBound...] - let parts = suffix.components(separatedBy: "|") - if let key = parts.first { - extractedNostrPubkey = String(key) - } - } else if content.contains(":") { - // Fallback: simple format FAVORITE:TRUE:npub... - let parts = content.components(separatedBy: ":") - if parts.count >= 3 { - extractedNostrPubkey = String(parts[2]) - } - } - - SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session) - - // If they favorited us and provided their Nostr key, ensure it's stored - if isFavorite && extractedNostrPubkey != nil { - SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: extractedNostrPubkey, - peerNickname: senderNickname - ) - } - - // Show notification - NotificationService.shared.sendLocalNotification( - title: isFavorite ? "New Favorite" : "Favorite Removed", - body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", - identifier: "fav-\(UUID().uuidString)" + @MainActor + func sendDeliveryAckViaNostrEmbedded( + _ message: BitchatMessage, + wasReadBefore: Bool, + senderPubkey: String, + key: Data? + ) { + nostrCoordinator.sendDeliveryAckViaNostrEmbedded( + message, + wasReadBefore: wasReadBefore, + senderPubkey: senderPubkey, + key: key ) } + @MainActor + func handleFavoriteNotification(content: String, from nostrPubkey: String) { + nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey) + } + + @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { - // Find peer Nostr key - guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), - relationship.peerNostrPublicKey != nil else { - SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) - return - } - - let peerID = PeerID(hexData: noisePublicKey) - - // Route via message router - messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite) } - private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { - guard content.hasPrefix("bitchat1:") else { return nil } - let encoded = String(content.dropFirst("bitchat1:".count)) - let maxBytes = FileTransferLimits.maxFramedFileBytes - // Base64url length upper bound for maxBytes (padded length; unpadded is <= this). - let maxEncoded = ((maxBytes + 2) / 3) * 4 - guard encoded.count <= maxEncoded else { return nil } - guard let packetData = Self.base64URLDecode(encoded), - packetData.count <= maxBytes - else { return nil } - return BitchatPacket.from(packetData) - } - - // MARK: - Geohash Nickname Resolution (for /block in geohash) - + @MainActor func nostrPubkeyForDisplayName(_ name: String) -> String? { - // Look up current visible geohash participants for an exact displayName match - for p in visibleGeohashPeople() { - if p.displayName == name { - return p.id - } - } - // Also check nickname cache directly - for (pub, nick) in geoNicknames { - if nick == name { return pub } - } - return nil + nostrCoordinator.nostrPubkeyForDisplayName(name) } - + + @MainActor func startGeohashDM(withPubkeyHex hex: String) { - let convKey = PeerID(nostr_: hex) - nostrKeyMapping[convKey] = hex - startPrivateChat(with: convKey) + nostrCoordinator.startGeohashDM(withPubkeyHex: hex) } - + + @MainActor func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { - return nostrKeyMapping[senderID] + nostrCoordinator.fullNostrHex(forSenderPeerID: senderID) } - + + @MainActor func geohashDisplayName(for convKey: PeerID) -> String { - guard let full = nostrKeyMapping[convKey] else { - return convKey.bare - } - return displayNameForNostrPubkey(full) + nostrCoordinator.geohashDisplayName(for: convKey) } } diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 146c3959..fb288dbc 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -5,193 +5,23 @@ // Private chat and media transfer logic for ChatViewModel // -import Foundation -import Combine -import BitLogger import BitFoundation +import Foundation import SwiftUI extension ChatViewModel { - // MARK: - Private Chat Sending - - /// Sends an encrypted private message to a specific peer. - /// - Parameters: - /// - content: The message content to encrypt and send - /// - peerID: The recipient's peer ID - /// - Note: Automatically establishes Noise encryption if not already active @MainActor func sendPrivateMessage(_ content: String, to peerID: PeerID) { - guard !content.isEmpty else { return } - - // Check if blocked - if unifiedPeerService.isBlocked(peerID) { - let nickname = meshService.peerNickname(peerID: peerID) ?? "user" - addSystemMessage( - String( - format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), - locale: .current, - nickname - ) - ) - return - } - - // Geohash DM routing: conversation keys start with "nostr_" - if peerID.isGeoDM { - sendGeohashDM(content, to: peerID) - return - } - - // Determine routing method and recipient nickname - guard let noiseKey = Data(hexString: peerID.id) else { return } - let isConnected = meshService.isPeerConnected(peerID) - let isReachable = meshService.isPeerReachable(peerID) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) - let isMutualFavorite = favoriteStatus?.isMutual ?? false - let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - - // Get nickname from various sources - var recipientNickname = meshService.peerNickname(peerID: peerID) - if recipientNickname == nil && favoriteStatus != nil { - recipientNickname = favoriteStatus?.peerNickname - } - recipientNickname = recipientNickname ?? "user" - - // Generate message ID - let messageID = UUID().uuidString - - // Create the message object - let message = BitchatMessage( - id: messageID, - sender: nickname, - content: content, - timestamp: Date(), - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: recipientNickname, - senderPeerID: meshService.myPeerID, - mentions: nil, - deliveryStatus: .sending - ) - - // Add to local chat - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - privateChats[peerID]?.append(message) - - // Trigger UI update for sent message - objectWillChange.send() - - // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) - if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) - // Optimistically mark as sent for both transports; delivery/read will update subsequently - if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[idx].deliveryStatus = .sent - } - } else { - // Update delivery status to failed - if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[index].deliveryStatus = .failed( - reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") - ) - } - let name = recipientNickname ?? "user" - addSystemMessage( - String( - format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), - locale: .current, - name - ) - ) - } + privateConversationCoordinator.sendPrivateMessage(content, to: peerID) } - + + @MainActor func sendGeohashDM(_ content: String, to peerID: PeerID) { - guard case .location(let ch) = activeChannel else { - addSystemMessage( - String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") - ) - return - } - let messageID = UUID().uuidString - - // Local echo in the DM thread - let message = BitchatMessage( - id: messageID, - sender: nickname, - content: content, - timestamp: Date(), - isRelay: false, - isPrivate: true, - recipientNickname: nickname, - senderPeerID: meshService.myPeerID, - deliveryStatus: .sending - ) - - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - privateChats[peerID]?.append(message) - objectWillChange.send() - - // Resolve recipient hex from mapping - guard let recipientHex = nostrKeyMapping[peerID] else { - if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[msgIdx].deliveryStatus = .failed( - reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") - ) - } - return - } - - // Respect geohash blocks - if identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { - if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[msgIdx].deliveryStatus = .failed( - reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") - ) - } - addSystemMessage( - String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") - ) - return - } - - // Send via Nostr using per-geohash identity - do { - let id = try idBridge.deriveIdentity(forGeohash: ch.geohash) - // Prevent messaging ourselves - if recipientHex.lowercased() == id.publicKeyHex.lowercased() { - if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[idx].deliveryStatus = .failed( - reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") - ) - } - return - } - SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session) - let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) - nostrTransport.senderPeerID = meshService.myPeerID - nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID) - if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[msgIdx].deliveryStatus = .sent - } - } catch { - if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - privateChats[peerID]?[idx].deliveryStatus = .failed( - reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") - ) - } - } + privateConversationCoordinator.sendGeohashDM(content, to: peerID) } - // MARK: - Private Chat Handling (Geohash/Ephemeral) - + @MainActor func handlePrivateMessage( _ payload: NoisePayload, senderPubkey: String, @@ -199,409 +29,97 @@ extension ChatViewModel { id: NostrIdentity, messageTimestamp: Date ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } - let messageId = pm.messageID - - SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session) - - sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) - - // Respect geohash blocks - if identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { - return - } - - // Duplicate check - if privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } - for (_, arr) in privateChats { - if arr.contains(where: { $0.id == messageId }) { - return - } - } - - let senderName = displayNameForNostrPubkey(senderPubkey) - let msg = BitchatMessage( - id: messageId, - sender: senderName, - content: pm.content, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: nickname, - senderPeerID: convKey, - deliveryStatus: .delivered(to: nickname, at: Date()) + privateConversationCoordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp ) - - if privateChats[convKey] == nil { - privateChats[convKey] = [] - } - privateChats[convKey]?.append(msg) - - let isViewing = selectedPrivateChatPeer == convKey - let wasReadBefore = sentReadReceipts.contains(messageId) - let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 - let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage - if shouldMarkUnread { - unreadPrivateMessages.insert(convKey) - } - - // Send READ if viewing this conversation - if isViewing { - sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) - } - - // Notify for truly unread and recent messages when not viewing - if !isViewing && shouldMarkUnread { - NotificationService.shared.sendPrivateMessageNotification( - from: senderName, - message: pm.content, - peerID: convKey - ) - } - - objectWillChange.send() } - + + @MainActor func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - - if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - privateChats[convKey]?[idx].deliveryStatus = .delivered(to: displayNameForNostrPubkey(senderPubkey), at: Date()) - objectWillChange.send() - SecureLogger.info("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) - } else { - SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) - } + privateConversationCoordinator.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) } - + + @MainActor func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { - guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - - if let idx = privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - privateChats[convKey]?[idx].deliveryStatus = .read(by: displayNameForNostrPubkey(senderPubkey), at: Date()) - objectWillChange.send() - SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) - } else { - SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) - } + privateConversationCoordinator.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) } + @MainActor func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !sentGeoDeliveryAcks.contains(messageId) else { return } - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) - sentGeoDeliveryAcks.insert(messageId) + privateConversationCoordinator.sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id) } - + + @MainActor func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !sentReadReceipts.contains(messageId) else { return } - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) - sentReadReceipts.insert(messageId) - } - - // MARK: - Media Transfers - - private enum MediaSendError: Error { - case encodingFailed - case tooLarge - case copyFailed + privateConversationCoordinator.sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubKey, from: id) } @MainActor func sendVoiceNote(at url: URL) { - guard canSendMediaInCurrentContext else { - SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) - try? FileManager.default.removeItem(at: url) - addSystemMessage("Voice notes are only available in mesh chats.") - return - } - - let targetPeer = selectedPrivateChatPeer - let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer) - let messageID = message.id - let transferId = makeTransferID(messageID: messageID) - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - do { - // Security H1: Check file size BEFORE reading into memory - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - guard let fileSize = attrs[.size] as? Int, - fileSize <= FileTransferLimits.maxVoiceNoteBytes else { - let size = (attrs[.size] as? Int) ?? 0 - SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) - try? FileManager.default.removeItem(at: url) - await MainActor.run { - self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") - } - return - } - - let data = try Data(contentsOf: url) - let packet = BitchatFilePacket( - fileName: url.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "audio/mp4", - content: data - ) - guard packet.encode() != nil else { throw MediaSendError.encodingFailed } - await MainActor.run { - self.registerTransfer(transferId: transferId, messageID: messageID) - if let peerID = targetPeer { - self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) - } else { - self.meshService.sendFileBroadcast(packet, transferId: transferId) - } - } - } catch { - SecureLogger.error("Voice note send failed: \(error)", category: .session) - await MainActor.run { - self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") - } - } - } + mediaTransferCoordinator.sendVoiceNote(at: url) } #if os(iOS) func processThenSendImage(_ image: UIImage?) { - guard let image else { return } - Task.detached { - do { - let processedURL = try ImageUtils.processImage(image) - await MainActor.run { - self.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } + mediaTransferCoordinator.processThenSendImage(image) } #elseif os(macOS) func processThenSendImage(from url: URL?) { - guard let url else { return } - Task.detached { - do { - let processedURL = try ImageUtils.processImage(at: url) - await MainActor.run { - self.sendImage(from: processedURL) - } - } catch { - SecureLogger.error("Image processing failed: \(error)", category: .session) - } - } + mediaTransferCoordinator.processThenSendImage(from: url) } #endif @MainActor func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { - guard canSendMediaInCurrentContext else { - SecureLogger.info("Image send blocked outside mesh/private context", category: .session) - cleanup?() - addSystemMessage("Images are only available in mesh chats.") - return - } - - let targetPeer = selectedPrivateChatPeer - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - var processedURL: URL? - do { - let outputURL = try ImageUtils.processImage(at: sourceURL) - processedURL = outputURL - let data = try Data(contentsOf: outputURL) - guard data.count <= FileTransferLimits.maxImageBytes else { - SecureLogger.warning("Processed image exceeds size limit (\(data.count) bytes)", category: .session) - await MainActor.run { - self.addSystemMessage("Image is too large to send.") - } - try? FileManager.default.removeItem(at: outputURL) - return - } - let packet = BitchatFilePacket( - fileName: outputURL.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: "image/jpeg", - content: data - ) - guard packet.encode() != nil else { throw MediaSendError.encodingFailed } - await MainActor.run { - let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer) - let messageID = message.id - let transferId = self.makeTransferID(messageID: messageID) - self.registerTransfer(transferId: transferId, messageID: messageID) - if let peerID = targetPeer { - self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) - } else { - self.meshService.sendFileBroadcast(packet, transferId: transferId) - } - } - } catch { - SecureLogger.error("Image send preparation failed: \(error)", category: .session) - await MainActor.run { - self.addSystemMessage("Failed to prepare image for sending.") - } - if let url = processedURL { - try? FileManager.default.removeItem(at: url) - } - } - } + mediaTransferCoordinator.sendImage(from: sourceURL, cleanup: cleanup) } @MainActor func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage { - let timestamp = Date() - let message: BitchatMessage - - if let peerID = targetPeer { - message = BitchatMessage( - sender: nickname, - content: content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: nicknameForPeer(peerID), - senderPeerID: meshService.myPeerID, - deliveryStatus: .sending - ) - var chats = privateChats - chats[peerID, default: []].append(message) - privateChats = chats - trimMessagesIfNeeded() - } else { - let (displayName, senderPeerID) = currentPublicSender() - message = BitchatMessage( - sender: displayName, - content: content, - timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: senderPeerID, - deliveryStatus: .sending - ) - timelineStore.append(message, to: activeChannel) - messages = timelineStore.messages(for: activeChannel) - trimMessagesIfNeeded() - } - - let key = deduplicationService.normalizedContentKey(message.content) - deduplicationService.recordContentKey(key, timestamp: timestamp) - objectWillChange.send() - return message + mediaTransferCoordinator.enqueueMediaMessage(content: content, targetPeer: targetPeer) } @MainActor func registerTransfer(transferId: String, messageID: String) { - transferIdToMessageIDs[transferId, default: []].append(messageID) - messageIDToTransferId[messageID] = transferId + mediaTransferCoordinator.registerTransfer(transferId: transferId, messageID: messageID) } func makeTransferID(messageID: String) -> String { - "\(messageID)-\(UUID().uuidString)" + mediaTransferCoordinator.makeTransferID(messageID: messageID) } @MainActor func clearTransferMapping(for messageID: String) { - guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } - guard var queue = transferIdToMessageIDs[transferId] else { return } - if !queue.isEmpty { - if queue.first == messageID { - queue.removeFirst() - } else if let idx = queue.firstIndex(of: messageID) { - queue.remove(at: idx) - } - } - transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue + mediaTransferCoordinator.clearTransferMapping(for: messageID) } @MainActor func handleMediaSendFailure(messageID: String, reason: String) { - updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) - clearTransferMapping(for: messageID) + mediaTransferCoordinator.handleMediaSendFailure(messageID: messageID, reason: reason) } @MainActor func handleTransferEvent(_ event: TransferProgressManager.Event) { - switch event { - case .started(let id, let total): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } - updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) - case .updated(let id, let sent, let total): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } - updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) - case .completed(let id, _): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } - updateMessageDeliveryStatus(messageID, status: .sent) - clearTransferMapping(for: messageID) - case .cancelled(let id, _, _): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } - clearTransferMapping(for: messageID) - removeMessage(withID: messageID, cleanupFile: true) - } + mediaTransferCoordinator.handleTransferEvent(event) } func cleanupLocalFile(forMessage message: BitchatMessage) { - // Check both outgoing and incoming directories for thorough cleanup - let categories: [MimeType.Category] = [.audio, .image, .file] - guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), - let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, - let base = try? applicationFilesDirectory(), - // Security: Extract only the last path component to prevent directory traversal - let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, - safeFilename != "." && safeFilename != ".." - else { - return - } - - // Try all possible locations (outgoing and incoming) - let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] } - for subdir in subdirs { - let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) - - // Security: Verify target is within expected directory before deletion - guard target.path.hasPrefix(base.path) else { continue } - - do { - try FileManager.default.removeItem(at: target) - } catch CocoaError.fileNoSuchFile { - // Expected - file not in this directory - } catch { - SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session) - } - } - } - - func applicationFilesDirectory() throws -> URL { - let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - let filesDir = base.appendingPathComponent("files", isDirectory: true) - try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil) - return filesDir + mediaTransferCoordinator.cleanupLocalFile(forMessage: message) } @MainActor func cancelMediaSend(messageID: String) { - if let transferId = messageIDToTransferId[messageID], - let active = transferIdToMessageIDs[transferId]?.first, - active == messageID { - meshService.cancelTransfer(transferId) - } - clearTransferMapping(for: messageID) - removeMessage(withID: messageID, cleanupFile: true) + mediaTransferCoordinator.cancelMediaSend(messageID: messageID) } @MainActor func deleteMediaMessage(messageID: String) { - clearTransferMapping(for: messageID) - removeMessage(withID: messageID, cleanupFile: true) + mediaTransferCoordinator.deleteMediaMessage(messageID: messageID) } - - // MARK: - Private Chat Handling (Main) @MainActor func handlePrivateMessage( @@ -612,249 +130,46 @@ extension ChatViewModel { messageTimestamp: Date, senderPubkey: String ) { - guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return } - let messageId = pm.messageID - let messageContent = pm.content - - // Favorite/unfavorite notifications embedded as private messages - if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") { - if let key = actualSenderNoiseKey { - handleFavoriteNotificationFromMesh(messageContent, from: PeerID(hexData: key), senderNickname: senderNickname) - } - return - } - - if isDuplicateMessage(messageId, targetPeerID: targetPeerID) { - return - } - - let wasReadBefore = sentReadReceipts.contains(messageId) - - // Is viewing? - var isViewingThisChat = false - if selectedPrivateChatPeer == targetPeerID { - isViewingThisChat = true - } else if let selectedPeer = selectedPrivateChatPeer, - let selectedPeerData = unifiedPeerService.getPeer(by: selectedPeer), - let key = actualSenderNoiseKey, - selectedPeerData.noisePublicKey == key { - isViewingThisChat = true - } - - // Recency check - let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 - let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage - - let message = BitchatMessage( - id: messageId, - sender: senderNickname, - content: messageContent, - timestamp: messageTimestamp, - isRelay: false, - isPrivate: true, - recipientNickname: nickname, - senderPeerID: targetPeerID, - deliveryStatus: .delivered(to: nickname, at: Date()) + privateConversationCoordinator.handlePrivateMessage( + payload, + actualSenderNoiseKey: actualSenderNoiseKey, + senderNickname: senderNickname, + targetPeerID: targetPeerID, + messageTimestamp: messageTimestamp, + senderPubkey: senderPubkey ) - - addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) - mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) - - // Using simplified internal helper in this file (or make the main one internal) - // sendDeliveryAckViaNostrEmbedded is in ChatViewModel+Nostr.swift and is internal. - // However, it was missing in ChatViewModel+Nostr.swift in previous step check? - // Wait, I added `sendDeliveryAckViaNostrEmbedded` to `ChatViewModel+Nostr.swift` in Step 19? - // Let's re-check `ChatViewModel+Nostr.swift` content in my mind. - // I see `sendDeliveryAckViaNostrEmbedded` in `ChatViewModel+Nostr.swift` in the output of step 33. - // So I can call it. - sendDeliveryAckViaNostrEmbedded( - message, - wasReadBefore: wasReadBefore, - senderPubkey: senderPubkey, - key: actualSenderNoiseKey - ) - - if wasReadBefore { - // do nothing - } else if isViewingThisChat { - handleViewingThisChat( - message, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - senderPubkey: senderPubkey - ) - } else { - markAsUnreadIfNeeded( - shouldMarkAsUnread: shouldMarkAsUnread, - targetPeerID: targetPeerID, - key: actualSenderNoiseKey, - isRecentMessage: isRecentMessage, - senderNickname: senderNickname, - messageContent: messageContent - ) - } - - objectWillChange.send() } - - /// Handle incoming private message (Mesh) + @MainActor func handlePrivateMessage(_ message: BitchatMessage) { - SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) - let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) - - guard let peerID = senderPeerID else { - SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) - return - } - - // Check if this is a favorite/unfavorite notification - if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") { - handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender) - return // Don't store as a regular message - } - - // Migrate chats if needed - migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) - - // IMPORTANT: Also consolidate messages from stable Noise key if this is an ephemeral peer - // This ensures Nostr messages appear in BLE chats - if peerID.id.count == 16 { // This is an ephemeral peer ID (8 bytes = 16 hex chars) - if let peer = unifiedPeerService.getPeer(by: peerID) { - let stableKeyHex = PeerID(hexData: peer.noisePublicKey) - - // If we have messages stored under the stable key, merge them - if stableKeyHex != peerID, let nostrMessages = privateChats[stableKeyHex], !nostrMessages.isEmpty { - // Merge messages from stable key into ephemeral peer ID storage - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - - // Add any messages that aren't already in the ephemeral storage - let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? []) - for nostrMessage in nostrMessages { - if !existingMessageIds.contains(nostrMessage.id) { - privateChats[peerID]?.append(nostrMessage) - } - } - - // Sort by timestamp - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - - // Clean up the stable key storage to avoid duplication - privateChats.removeValue(forKey: stableKeyHex) - - SecureLogger.info("📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", category: .session) - } - } - } - - // Avoid duplicates - if isDuplicateMessage(message.id, targetPeerID: peerID) { - return - } - - // Store the message - addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) - - // Mirror to ephemeral if needed (if we are talking to a stable key peer but have an ephemeral session) - // Actually, logic usually mirrors TO stable key storage if available? - // Or mirrors to ephemeral if we received on stable. - // Let's just use the existing helper which seems to mirror TO ephemeral. - // But we need to get the noise key. - let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey - mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) - - // Notifications and Read Receipts - let isViewing = selectedPrivateChatPeer == peerID - - if isViewing { - // Mark read immediately if viewing - // Use the incoming peerID directly - it has the established Noise session. - // Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session. - // Use meshService directly (not messageRouter) so it queues if peer disconnects. - let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) - meshService.sendReadReceipt(receipt, to: peerID) - sentReadReceipts.insert(message.id) - } else { - // Notify - unreadPrivateMessages.insert(peerID) - NotificationService.shared.sendPrivateMessageNotification( - from: message.sender, - message: message.content, - peerID: peerID - ) - } - - objectWillChange.send() + privateConversationCoordinator.handlePrivateMessage(message) } + @MainActor func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { - if privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { - return true - } - for (_, messages) in privateChats where messages.contains(where: { $0.id == messageId }) { - return true - } - return false + privateConversationCoordinator.isDuplicateMessage(messageId, targetPeerID: targetPeerID) } - + + @MainActor func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { - if privateChats[targetPeerID] == nil { - privateChats[targetPeerID] = [] - } - if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { - privateChats[targetPeerID]?[idx] = message - } else { - privateChats[targetPeerID]?.append(message) - } - // Sanitize to avoid duplicate IDs - privateChatManager.sanitizeChat(for: targetPeerID) + privateConversationCoordinator.addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) } - + @MainActor func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { - guard let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, - ephemeralPeerID != targetPeerID - else { - return - } - - if privateChats[ephemeralPeerID] == nil { - privateChats[ephemeralPeerID] = [] - } - if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { - privateChats[ephemeralPeerID]?[idx] = message - } else { - privateChats[ephemeralPeerID]?.append(message) - } - privateChatManager.sanitizeChat(for: ephemeralPeerID) + privateConversationCoordinator.mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: key) } - + @MainActor func handleViewingThisChat(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?, senderPubkey: String) { - unreadPrivateMessages.remove(targetPeerID) - if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { - unreadPrivateMessages.remove(ephemeralPeerID) - } - if !sentReadReceipts.contains(message.id) { - if let key { - let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) - SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) - messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) - sentReadReceipts.insert(message.id) - } else if let id = try? idBridge.getCurrentNostrIdentity() { - let nt = NostrTransport(keychain: keychain, idBridge: idBridge) - nt.senderPeerID = meshService.myPeerID - nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id) - sentReadReceipts.insert(message.id) - SecureLogger.debug("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session) - } - } + privateConversationCoordinator.handleViewingThisChat( + message, + targetPeerID: targetPeerID, + key: key, + senderPubkey: senderPubkey + ) } - + @MainActor func markAsUnreadIfNeeded( shouldMarkAsUnread: Bool, @@ -864,227 +179,42 @@ extension ChatViewModel { senderNickname: String, messageContent: String ) { - guard shouldMarkAsUnread else { return } - - unreadPrivateMessages.insert(targetPeerID) - if let key, - let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, - ephemeralPeerID != targetPeerID { - unreadPrivateMessages.insert(ephemeralPeerID) - } - if isRecentMessage { - NotificationService.shared.sendPrivateMessageNotification( - from: senderNickname, - message: messageContent, - peerID: targetPeerID - ) - } + privateConversationCoordinator.markAsUnreadIfNeeded( + shouldMarkAsUnread: shouldMarkAsUnread, + targetPeerID: targetPeerID, + key: key, + isRecentMessage: isRecentMessage, + senderNickname: senderNickname, + messageContent: messageContent + ) } - + @MainActor func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) { - // Parse the message format: "[FAVORITED]:npub..." or "[UNFAVORITED]:npub..." - let isFavorite = content.hasPrefix("[FAVORITED]") - let parts = content.split(separator: ":") - - // Extract Nostr public key if included - var nostrPubkey: String? = nil - if parts.count > 1 { - nostrPubkey = String(parts[1]) - SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) - } - - // Get the noise public key for this peer - let noiseKey = peerID.noiseKey ?? unifiedPeerService.getPeer(by: peerID)?.noisePublicKey - - guard let finalNoiseKey = noiseKey else { - SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) - return - } - // Determine prior state to avoid duplicate system messages on repeated notifications - let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false - - // Update the favorite relationship (idempotent storage) - FavoritesPersistenceService.shared.updatePeerFavoritedUs( - peerNoisePublicKey: finalNoiseKey, - favorited: isFavorite, - peerNickname: senderNickname, - peerNostrPublicKey: nostrPubkey + privateConversationCoordinator.handleFavoriteNotificationFromMesh( + content, + from: peerID, + senderNickname: senderNickname ) - - // If they favorited us and provided their Nostr key, ensure it's stored (log only) - if isFavorite && nostrPubkey != nil { - SecureLogger.info("💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...", category: .session) - } - - // Only show a system message when the state changes, and only in mesh - if prior != isFavorite { - let action = isFavorite ? "favorited" : "unfavorited" - addMeshOnlySystemMessage("\(senderNickname) \(action) you") - } } - - /// Process action messages (hugs, slaps) into system messages + + @MainActor func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { - let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") && - (message.content.contains("🫂") || message.content.contains("🐟") || - message.content.contains("took a screenshot")) - - if isActionMessage { - return BitchatMessage( - id: message.id, - sender: "system", - content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper - timestamp: message.timestamp, - isRelay: message.isRelay, - originalSender: message.originalSender, - isPrivate: message.isPrivate, - recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, - mentions: message.mentions, - deliveryStatus: message.deliveryStatus - ) - } - return message + privateConversationCoordinator.processActionMessage(message) } - - /// Migrate private chats when peer reconnects with new ID + @MainActor func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { - let currentFingerprint = getFingerprint(for: peerID) - - if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true { - var migratedMessages: [BitchatMessage] = [] - var oldPeerIDsToRemove: [PeerID] = [] - - // Only migrate messages from the last 24 hours to prevent old messages from flooding - let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) - - for (oldPeerID, messages) in privateChats { - if oldPeerID != peerID { - let oldFingerprint = peerIDToPublicKeyFingerprint[oldPeerID] - - // Filter messages to only recent ones - let recentMessages = messages.filter { $0.timestamp > cutoffTime } - - // Skip if no recent messages - guard !recentMessages.isEmpty else { continue } - - // Check fingerprint match first (most reliable) - if let currentFp = currentFingerprint, - let oldFp = oldFingerprint, - currentFp == oldFp { - migratedMessages.append(contentsOf: recentMessages) - - // Only remove old peer ID if we migrated ALL its messages - if recentMessages.count == messages.count { - oldPeerIDsToRemove.append(oldPeerID) - } else { - // Keep old messages in original location but don't show in UI - SecureLogger.info("📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)", category: .session) - } - - SecureLogger.info("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)", category: .session) - } else if currentFingerprint == nil || oldFingerprint == nil { - // Check if this chat contains messages with this sender by nickname - let isRelevantChat = recentMessages.contains { msg in - (msg.sender == senderNickname && msg.sender != nickname) || - (msg.sender == nickname && msg.recipientNickname == senderNickname) - } - - if isRelevantChat { - migratedMessages.append(contentsOf: recentMessages) - - // Only remove if all messages were migrated - if recentMessages.count == messages.count { - oldPeerIDsToRemove.append(oldPeerID) - } - - SecureLogger.warning("📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)", category: .session) - } - } - } - } - - // Remove old peer ID entries - if !oldPeerIDsToRemove.isEmpty { - // Track if we need to update selectedPrivateChatPeer - let needsSelectedUpdate = oldPeerIDsToRemove.contains { selectedPrivateChatPeer == $0 } - - for oldID in oldPeerIDsToRemove { - privateChats.removeValue(forKey: oldID) - unreadPrivateMessages.remove(oldID) - - // Also clean up fingerprint mapping - if peerIDToPublicKeyFingerprint[oldID] != nil { - peerIDToPublicKeyFingerprint.removeValue(forKey: oldID) - } - } - - if needsSelectedUpdate { - selectedPrivateChatPeer = peerID - } - } - - // Add migrated messages to new peer ID - if !migratedMessages.isEmpty { - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } - privateChats[peerID]?.append(contentsOf: migratedMessages) - - // Sort by timestamp - privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - - // De-duplicate just in case - privateChatManager.sanitizeChat(for: peerID) - - objectWillChange.send() - } - } + privateConversationCoordinator.migratePrivateChatsIfNeeded(for: peerID, senderNickname: senderNickname) } - + @MainActor func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - // Handle both ephemeral peer IDs and Noise key hex strings - var noiseKey: Data? - - // First check if peerID is a hex-encoded Noise key - if let hexKey = Data(hexString: peerID.id) { - noiseKey = hexKey - } else { - // It's an ephemeral peer ID, get the Noise key from UnifiedPeerService - if let peer = unifiedPeerService.getPeer(by: peerID) { - noiseKey = peer.noisePublicKey - } - } - - // Try mesh first for connected peers - if meshService.isPeerConnected(peerID) { - messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) - SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) - } else if let key = noiseKey { - // Send via Nostr for offline peers (using router) - messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) - } else { - SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) - } + privateConversationCoordinator.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } - /// Check if a message should be blocked based on sender @MainActor func isMessageBlocked(_ message: BitchatMessage) -> Bool { - if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { - // Check mesh/known peers first - if isPeerBlocked(peerID) { return true } - // Check geohash (Nostr) blocks using mapping to full pubkey - if peerID.isGeoChat || peerID.isGeoDM { - if let full = nostrKeyMapping[peerID]?.lowercased() { - if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true } - } - } - return false - } - return false + privateConversationCoordinator.isMessageBlocked(message) } } diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index 4c9d0213..3ccc8f1e 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -8,8 +8,8 @@ import SwiftUI struct CommandSuggestionsView: View { - @EnvironmentObject private var viewModel: ChatViewModel - @ObservedObject private var locationManager = LocationChannelManager.shared + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @Binding var messageText: String @@ -19,8 +19,8 @@ struct CommandSuggestionsView: View { private var filteredCommands: [CommandInfo] { guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] } - let isGeoPublic = locationManager.selectedChannel.isLocation - let isGeoDM = viewModel.selectedPrivateChatPeer?.isGeoDM == true + let isGeoPublic = locationChannelsModel.selectedChannel.isLocation + let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in command.alias.starts(with: messageText.lowercased()) } @@ -79,6 +79,11 @@ struct CommandSuggestionsView: View { idBridge: NostrIdentityBridge(), identityManager: SecureIdentityStateManager(keychain) ) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: viewModel.conversationStore + ) + let locationChannelsModel = LocationChannelsModel() CommandSuggestionsView( messageText: $messageText, @@ -86,5 +91,6 @@ struct CommandSuggestionsView: View { backgroundColor: .primary, secondaryTextColor: .secondary ) - .environmentObject(viewModel) + .environmentObject(privateConversationModel) + .environmentObject(locationChannelsModel) } diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 7b89a781..6b1be23a 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -11,7 +11,7 @@ import BitFoundation struct TextMessageView: View { @Environment(\.colorScheme) private var colorScheme: ColorScheme - @EnvironmentObject private var viewModel: ChatViewModel + @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage @State private var expandedMessageIDs: Set = [] @@ -24,13 +24,13 @@ struct TextMessageView: View { HStack(alignment: .top, spacing: 0) { let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty let isExpanded = expandedMessageIDs.contains(message.id) - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .frame(maxWidth: .infinity, alignment: .leading) // Delivery status indicator for private messages - if message.isPrivate && message.sender == viewModel.nickname, + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = message.deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) @@ -69,6 +69,20 @@ struct TextMessageView: View { #Preview { let keychain = PreviewKeychainManager() + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: NostrIdentityBridge(), + identityManager: SecureIdentityStateManager(keychain) + ) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: viewModel.conversationStore + ) + let conversationUIModel = ConversationUIModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel, + conversationStore: viewModel.conversationStore + ) Group { List { @@ -87,11 +101,5 @@ struct TextMessageView: View { } .environment(\.colorScheme, .dark) } - .environmentObject( - ChatViewModel( - keychain: keychain, - idBridge: NostrIdentityBridge(), - identityManager: SecureIdentityStateManager(keychain) - ) - ) + .environmentObject(conversationUIModel) } diff --git a/bitchat/Views/ContentComposerView.swift b/bitchat/Views/ContentComposerView.swift new file mode 100644 index 00000000..424a649b --- /dev/null +++ b/bitchat/Views/ContentComposerView.swift @@ -0,0 +1,248 @@ +import SwiftUI +#if os(iOS) +import UIKit +#endif + +struct ContentComposerView: View { + @EnvironmentObject private var conversationUIModel: ConversationUIModel + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @Environment(\.colorScheme) private var colorScheme + + @Binding var messageText: String + var isTextFieldFocused: FocusState.Binding + @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel + @Binding var autocompleteDebounceTimer: Timer? + + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + let onSendMessage: () -> Void + + #if os(iOS) + @Binding var showImagePicker: Bool + @Binding var imagePickerSourceType: UIImagePickerController.SourceType + #else + @Binding var showMacImagePicker: Bool + #endif + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in + Button(action: { + _ = conversationUIModel.completeNickname(suggestion, in: &messageText) + }) { + HStack { + Text(suggestion) + .font(.bitchatSystem(size: 11, design: .monospaced)) + .foregroundColor(textColor) + .fontWeight(.medium) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .background(Color.gray.opacity(0.1)) + } + } + .background(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) + ) + .padding(.horizontal, 12) + } + + CommandSuggestionsView( + messageText: $messageText, + textColor: textColor, + backgroundColor: backgroundColor, + secondaryTextColor: secondaryTextColor + ) + + if voiceRecordingVM.state.isActive { + recordingIndicator + } + + HStack(alignment: .center, spacing: 4) { + TextField( + "", + text: $messageText, + prompt: Text( + String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") + ) + .foregroundColor(secondaryTextColor.opacity(0.6)) + ) + .textFieldStyle(.plain) + .font(.bitchatSystem(size: 15, design: .monospaced)) + .foregroundColor(textColor) + .focused(isTextFieldFocused) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.sentences) + #endif + .submitLabel(.send) + .onSubmit(onSendMessage) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) + ) + .modifier(FocusEffectDisabledModifier()) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: messageText) { newValue in + autocompleteDebounceTimer?.invalidate() + autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in + let cursorPosition = newValue.count + Task { @MainActor in + conversationUIModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) + } + } + } + + HStack(alignment: .center, spacing: 4) { + if conversationUIModel.canSendMediaInCurrentContext { + attachmentButton + } + + sendOrMicButton + } + } + } + .padding(.horizontal, 6) + .padding(.top, 6) + .padding(.bottom, 8) + .background(backgroundColor.opacity(0.95)) + .onDisappear { + autocompleteDebounceTimer?.invalidate() + } + } +} + +private extension ContentComposerView { + var recordingIndicator: some View { + HStack(spacing: 12) { + Image(systemName: "waveform.circle.fill") + .foregroundColor(.red) + .font(.bitchatSystem(size: 20)) + TimelineView(.periodic(from: .now, by: 0.05)) { context in + Text( + "recording \(voiceRecordingVM.formattedDuration(for: context.date))", + comment: "Voice note recording duration indicator" + ) + .font(.bitchatSystem(size: 13, design: .monospaced)) + .foregroundColor(.red) + } + Spacer() + Button(action: voiceRecordingVM.cancel) { + Label("Cancel", systemImage: "xmark.circle") + .labelStyle(.iconOnly) + .font(.bitchatSystem(size: 18)) + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.red.opacity(0.15)) + ) + } + + var composerAccentColor: Color { + privateConversationModel.selectedPeerID != nil ? Color.orange : textColor + } + + var attachmentButton: some View { + #if os(iOS) + Image(systemName: "camera.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .onTapGesture { + imagePickerSourceType = .photoLibrary + showImagePicker = true + } + .onLongPressGesture(minimumDuration: 0.3) { + imagePickerSourceType = .camera + showImagePicker = true + } + .accessibilityLabel("Tap for library, long press for camera") + #else + Button(action: { showMacImagePicker = true }) { + Image(systemName: "photo.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(composerAccentColor) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel("Choose photo") + #endif + } + + @ViewBuilder + var sendOrMicButton: some View { + let hasText = !messageText.trimmed.isEmpty + if conversationUIModel.canSendMediaInCurrentContext { + ZStack { + micButtonView + .opacity(hasText ? 0 : 1) + .allowsHitTesting(!hasText) + sendButtonView(enabled: hasText) + .opacity(hasText ? 1 : 0) + .allowsHitTesting(hasText) + } + .frame(width: 36, height: 36) + } else { + sendButtonView(enabled: hasText) + .frame(width: 36, height: 36) + } + } + + var micButtonView: some View { + Image(systemName: "mic.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .overlay( + Color.clear + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext) + } + .onEnded { _ in + voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote) + } + ) + ) + .accessibilityLabel("Hold to record a voice note") + } + + func sendButtonView(enabled: Bool) -> some View { + let activeColor = composerAccentColor + return Button(action: onSendMessage) { + Image(systemName: "arrow.up.circle.fill") + .font(.bitchatSystem(size: 24)) + .foregroundColor(enabled ? activeColor : Color.gray) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled(!enabled) + .accessibilityLabel( + String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") + ) + .accessibilityHint( + enabled + ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") + : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") + ) + } +} diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift new file mode 100644 index 00000000..baa15034 --- /dev/null +++ b/bitchat/Views/ContentHeaderView.swift @@ -0,0 +1,315 @@ +import SwiftUI +#if os(iOS) +import UIKit +#endif + +struct ContentHeaderView: View { + @EnvironmentObject private var appChromeModel: AppChromeModel + @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @Binding var showSidebar: Bool + @Binding var showVerifySheet: Bool + @Binding var showLocationNotes: Bool + @Binding var notesGeohash: String? + var isNicknameFieldFocused: FocusState.Binding + + let headerHeight: CGFloat + let headerPeerIconSize: CGFloat + let headerPeerCountFontSize: CGFloat + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + + var body: some View { + HStack(spacing: 0) { + Text(verbatim: "bitchat/") + .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + .onTapGesture(count: 3) { + appChromeModel.panicClearAllData() + } + .onTapGesture(count: 1) { + appChromeModel.presentAppInfo() + } + + HStack(spacing: 0) { + Text(verbatim: "@") + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + TextField( + "content.input.nickname_placeholder", + text: Binding( + get: { appChromeModel.nickname }, + set: { appChromeModel.setNickname($0) } + ) + ) + .textFieldStyle(.plain) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .frame(maxWidth: 80) + .foregroundColor(textColor) + .focused(isNicknameFieldFocused) + .autocorrectionDisabled(true) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .modifier(FocusEffectDisabledModifier()) + .onChange(of: isNicknameFieldFocused.wrappedValue) { isFocused in + if !isFocused { + appChromeModel.validateAndSaveNickname() + } + } + .onSubmit { + appChromeModel.validateAndSaveNickname() + } + } + + Spacer() + + let countAndColor = channelPeopleCountAndColor() + let headerCountColor = countAndColor.1 + let headerOtherPeersCount: Int = { + if case .location = locationChannelsModel.selectedChannel { + return peerListModel.visibleGeohashPeerCount + } + return countAndColor.0 + }() + + HStack(spacing: 10) { + if appChromeModel.hasUnreadPrivateMessages { + Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { + Image(systemName: "envelope.fill") + .font(.bitchatSystem(size: 12)) + .foregroundColor(Color.orange) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") + ) + } + + if case .mesh = locationChannelsModel.selectedChannel, + locationChannelsModel.permissionState == .authorized { + Button(action: { + locationChannelsModel.enableAndRefresh() + notesGeohash = locationChannelsModel.currentBuildingGeohash + showLocationNotes = true + }) { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "note.text") + .font(.bitchatSystem(size: 12)) + .foregroundColor(Color.orange.opacity(0.8)) + .padding(.top, 1) + } + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") + ) + } + + if case .location(let channel) = locationChannelsModel.selectedChannel { + Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { + Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") + .font(.bitchatSystem(size: 12)) + } + .buttonStyle(.plain) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), + locale: .current, + channel.geohash + ) + ) + } + + Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) { + let badgeText: String = { + switch locationChannelsModel.selectedChannel { + case .mesh: return "#mesh" + case .location(let channel): return "#\(channel.geohash)" + } + }() + let badgeColor: Color = { + switch locationChannelsModel.selectedChannel { + case .mesh: + return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) + case .location: + return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + }() + + Text(badgeText) + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(badgeColor) + .lineLimit(headerLineLimit) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + .accessibilityLabel( + String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") + ) + } + .buttonStyle(.plain) + .padding(.leading, 4) + .padding(.trailing, 2) + + HStack(spacing: 4) { + Image(systemName: "person.2.fill") + .font(.system(size: headerPeerIconSize, weight: .regular)) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), + locale: .current, + headerOtherPeersCount + ) + ) + Text("\(headerOtherPeersCount)") + .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced)) + .accessibilityHidden(true) + } + .foregroundColor(headerCountColor) + .padding(.leading, 2) + .lineLimit(headerLineLimit) + .fixedSize(horizontal: true, vertical: false) + } + .layoutPriority(3) + .onTapGesture { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar.toggle() + } + } + .sheet(isPresented: $showVerifySheet) { + VerificationSheetView(isPresented: $showVerifySheet) + .environmentObject(verificationModel) + } + } + .frame(height: headerHeight) + .padding(.horizontal, 12) + .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { + LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) + .environmentObject(locationChannelsModel) + .environmentObject(peerListModel) + } + .sheet(isPresented: $showLocationNotes, onDismiss: { + notesGeohash = nil + }) { + Group { + if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash { + LocationNotesView( + geohash: geohash, + senderNickname: appChromeModel.nickname + ) + .environmentObject(locationChannelsModel) + } else { + ContentLocationNotesUnavailableView( + showLocationNotes: $showLocationNotes, + headerHeight: headerHeight, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor + ) + .environmentObject(locationChannelsModel) + } + } + .onAppear { + locationChannelsModel.enableLocationChannels() + locationChannelsModel.beginLiveRefresh() + } + .onDisappear { + locationChannelsModel.endLiveRefresh() + } + .onChange(of: locationChannelsModel.availableChannels) { channels in + if let current = channels.first(where: { $0.level == .building })?.geohash, + notesGeohash != current { + notesGeohash = current + #if os(iOS) + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + #endif + } + } + } + .onAppear { + locationChannelsModel.refreshMeshChannelsIfNeeded() + } + .onChange(of: locationChannelsModel.selectedChannel) { _ in + locationChannelsModel.refreshMeshChannelsIfNeeded() + } + .onChange(of: locationChannelsModel.permissionState) { _ in + locationChannelsModel.refreshMeshChannelsIfNeeded() + } + .alert("content.alert.screenshot.title", isPresented: $appChromeModel.showScreenshotPrivacyWarning) { + Button("common.ok", role: .cancel) {} + } message: { + Text("content.alert.screenshot.message") + } + .background(backgroundColor.opacity(0.95)) + } +} + +private extension ContentHeaderView { + var headerLineLimit: Int? { + dynamicTypeSize.isAccessibilitySize ? 2 : 1 + } + + func channelPeopleCountAndColor() -> (Int, Color) { + switch locationChannelsModel.selectedChannel { + case .location: + let count = peerListModel.visibleGeohashPeerCount + let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + return (count, count > 0 ? standardGreen : Color.secondary) + case .mesh: + let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) + let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary + return (peerListModel.reachableMeshPeerCount, color) + } + } +} + +private struct ContentLocationNotesUnavailableView: View { + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + + @Binding var showLocationNotes: Bool + + let headerHeight: CGFloat + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + + var body: some View { + VStack(spacing: 12) { + HStack { + Text("content.notes.title") + .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) + Spacer() + Button(action: { showLocationNotes = false }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) + } + .frame(height: headerHeight) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + Text("content.notes.location_unavailable") + .font(.bitchatSystem(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + Button("content.location.enable") { + locationChannelsModel.enableAndRefresh() + } + .buttonStyle(.bordered) + Spacer() + } + .background(backgroundColor) + .foregroundColor(textColor) + } +} diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift new file mode 100644 index 00000000..35edede8 --- /dev/null +++ b/bitchat/Views/ContentSheetViews.swift @@ -0,0 +1,517 @@ +import BitFoundation +import SwiftUI +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +struct ContentPeopleSheetView: View { + @EnvironmentObject private var appChromeModel: AppChromeModel + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject private var conversationUIModel: ConversationUIModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel + + @Binding var showSidebar: Bool + @Binding var messageText: String + @Binding var selectedMessageSender: String? + @Binding var selectedMessageSenderID: PeerID? + @Binding var imagePreviewURL: URL? + @Binding var windowCountPublic: Int + @Binding var windowCountPrivate: [PeerID: Int] + @Binding var isAtBottomPrivate: Bool + var isTextFieldFocused: FocusState.Binding + @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel + @Binding var autocompleteDebounceTimer: Timer? + + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + let headerHeight: CGFloat + let onSendMessage: () -> Void + + #if os(iOS) + @Binding var showImagePicker: Bool + @Binding var imagePickerSourceType: UIImagePickerController.SourceType + #else + @Binding var showMacImagePicker: Bool + #endif + + var body: some View { + NavigationStack { + Group { + if privateConversationModel.selectedPeerID != nil { + #if os(iOS) + ContentPrivateChatSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + headerHeight: headerHeight, + onSendMessage: onSendMessage, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentPrivateChatSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + headerHeight: headerHeight, + onSendMessage: onSendMessage, + showMacImagePicker: $showMacImagePicker + ) + #endif + } else { + ContentPeopleListView( + showSidebar: $showSidebar, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + headerHeight: headerHeight + ) + } + } + .navigationDestination(isPresented: Binding( + get: { appChromeModel.showingFingerprintFor != nil && (showSidebar || privateConversationModel.selectedPeerID != nil) }, + set: { isPresented in + if !isPresented { + appChromeModel.clearFingerprint() + } + } + )) { + if let peerID = appChromeModel.showingFingerprintFor { + FingerprintView(peerID: peerID) + .environmentObject(verificationModel) + } + } + } + .background(backgroundColor) + .foregroundColor(textColor) + #if os(macOS) + .frame(minWidth: 420, minHeight: 520) + #endif + #if os(iOS) + .fullScreenCover(isPresented: Binding( + get: { showImagePicker && (showSidebar || privateConversationModel.selectedPeerID != nil) }, + set: { newValue in + if !newValue { + showImagePicker = false + } + } + )) { + ImagePickerView(sourceType: imagePickerSourceType) { image in + showImagePicker = false + conversationUIModel.processSelectedImage(image) + } + .ignoresSafeArea() + } + #endif + #if os(macOS) + .sheet(isPresented: $showMacImagePicker) { + MacImagePickerView { url in + showMacImagePicker = false + conversationUIModel.processSelectedImage(from: url) + } + } + #endif + } +} + +private struct ContentPeopleListView: View { + @EnvironmentObject private var appChromeModel: AppChromeModel + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel + @Environment(\.dismiss) private var dismiss + + @Binding var showSidebar: Bool + + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + let headerHeight: CGFloat + + @State private var showVerifySheet = false + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text(peopleSheetTitle) + .font(.bitchatSystem(size: 18, design: .monospaced)) + .foregroundColor(textColor) + Spacer() + if case .mesh = locationChannelsModel.selectedChannel { + Button(action: { showVerifySheet = true }) { + Image(systemName: "qrcode") + .font(.bitchatSystem(size: 14)) + } + .buttonStyle(.plain) + .help( + String(localized: "content.help.verification", comment: "Help text for verification button") + ) + } + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + dismiss() + showSidebar = false + showVerifySheet = false + privateConversationModel.endConversation() + } + }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + + let activeText = String.localizedStringWithFormat( + String(localized: "%@ active", comment: "Count of active users in the people sheet"), + "\(peopleSheetActiveCount)" + ) + + if let subtitle = peopleSheetSubtitle { + let subtitleColor: Color = { + switch locationChannelsModel.selectedChannel { + case .mesh: + return Color.blue + case .location: + return Color.green + } + }() + + HStack(spacing: 6) { + Text(subtitle) + .foregroundColor(subtitleColor) + Text(activeText) + .foregroundColor(.secondary) + } + .font(.bitchatSystem(size: 12, design: .monospaced)) + } else { + Text(activeText) + .font(.bitchatSystem(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + .background(backgroundColor) + + ScrollView { + VStack(alignment: .leading, spacing: 6) { + if case .location = locationChannelsModel.selectedChannel { + GeohashPeopleList( + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPerson: { + showSidebar = true + } + ) + } else { + MeshPeerList( + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPeer: { peerID in + peerListModel.startConversation(with: peerID) + showSidebar = true + }, + onToggleFavorite: { peerID in + peerListModel.toggleFavorite(peerID: peerID) + }, + onShowFingerprint: { peerID in + appChromeModel.showFingerprint(for: peerID) + } + ) + } + } + .padding(.top, 4) + .id(peerListModel.renderID) + } + } + .sheet(isPresented: $showVerifySheet) { + VerificationSheetView(isPresented: $showVerifySheet) + .environmentObject(verificationModel) + } + } +} + +private extension ContentPeopleListView { + var peopleSheetTitle: String { + String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() + } + + var peopleSheetSubtitle: String? { + switch locationChannelsModel.selectedChannel { + case .mesh: + return "#mesh" + case .location(let channel): + return "#\(channel.geohash.lowercased())" + } + } + + var peopleSheetActiveCount: Int { + switch locationChannelsModel.selectedChannel { + case .mesh: + return peerListModel.reachableMeshPeerCount + case .location: + return peerListModel.visibleGeohashPeerCount + } + } +} + +private struct ContentPrivateChatSheetView: View { + @EnvironmentObject private var appChromeModel: AppChromeModel + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + + @Binding var showSidebar: Bool + @Binding var messageText: String + @Binding var selectedMessageSender: String? + @Binding var selectedMessageSenderID: PeerID? + @Binding var imagePreviewURL: URL? + @Binding var windowCountPublic: Int + @Binding var windowCountPrivate: [PeerID: Int] + @Binding var isAtBottomPrivate: Bool + var isTextFieldFocused: FocusState.Binding + @ObservedObject var voiceRecordingVM: VoiceRecordingViewModel + @Binding var autocompleteDebounceTimer: Timer? + + let backgroundColor: Color + let textColor: Color + let secondaryTextColor: Color + let headerHeight: CGFloat + let onSendMessage: () -> Void + + #if os(iOS) + @Binding var showImagePicker: Bool + @Binding var imagePickerSourceType: UIImagePickerController.SourceType + #else + @Binding var showMacImagePicker: Bool + #endif + + var body: some View { + VStack(spacing: 0) { + if let headerState = privateConversationModel.selectedHeaderState { + HStack(spacing: 12) { + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + privateConversationModel.endConversation() + } + }) { + Image(systemName: "chevron.left") + .font(.bitchatSystem(size: 12)) + .foregroundColor(textColor) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel( + String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") + ) + + Spacer(minLength: 0) + + HStack(spacing: 8) { + ContentPrivateHeaderInfoButton( + headerState: headerState, + headerHeight: headerHeight, + textColor: textColor + ) + + if headerState.supportsFavoriteToggle { + Button(action: { + privateConversationModel.toggleFavoriteForSelectedConversation() + }) { + Image(systemName: headerState.isFavorite ? "star.fill" : "star") + .font(.bitchatSystem(size: 14)) + .foregroundColor(headerState.isFavorite ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel( + headerState.isFavorite + ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") + : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") + ) + } + } + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + + Button(action: { + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + privateConversationModel.endConversation() + showSidebar = true + } + }) { + Image(systemName: "xmark") + .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + .frame(height: headerHeight) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 12) + .background(backgroundColor) + } + + MessageListView( + privatePeer: privateConversationModel.selectedPeerID, + isAtBottom: $isAtBottomPrivate, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + showSidebar: $showSidebar, + isTextFieldFocused: isTextFieldFocused + ) + .background(backgroundColor) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + + #if os(iOS) + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onSendMessage: onSendMessage, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onSendMessage: onSendMessage, + showMacImagePicker: $showMacImagePicker + ) + #endif + } + .background(backgroundColor) + .foregroundColor(textColor) + .highPriorityGesture( + DragGesture(minimumDistance: 25, coordinateSpace: .local) + .onEnded { value in + let horizontal = value.translation.width + let vertical = abs(value.translation.height) + guard horizontal > 80, vertical < 60 else { return } + withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { + showSidebar = true + privateConversationModel.endConversation() + } + } + ) + } +} + +private struct ContentPrivateHeaderInfoButton: View { + @EnvironmentObject private var appChromeModel: AppChromeModel + + let headerState: PrivateConversationHeaderState + let headerHeight: CGFloat + let textColor: Color + + var body: some View { + Button(action: { + appChromeModel.showFingerprint(for: headerState.headerPeerID) + }) { + HStack(spacing: 6) { + switch headerState.availability { + case .bluetoothConnected: + Image(systemName: "dot.radiowaves.left.and.right") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) + case .meshReachable: + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.bitchatSystem(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) + case .nostrAvailable: + Image(systemName: "globe") + .font(.bitchatSystem(size: 14)) + .foregroundColor(.purple) + .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) + case .offline: + EmptyView() + } + + Text(headerState.displayName) + .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + + if let encryptionStatus = headerState.encryptionStatus, + let icon = encryptionStatus.icon { + Image(systemName: icon) + .font(.bitchatSystem(size: 14)) + .foregroundColor( + encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured + ? textColor + : Color.red + ) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), + locale: .current, + encryptionStatus.accessibilityDescription + ) + ) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), + locale: .current, + headerState.displayName + ) + ) + .accessibilityHint( + String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") + ) + .frame(height: headerHeight) + } +} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 88b2f19b..420f72c6 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -9,17 +9,14 @@ import SwiftUI #if os(iOS) import UIKit -#endif -#if os(macOS) +#elseif os(macOS) import AppKit #endif -import UniformTypeIdentifiers -import BitLogger import BitFoundation /// On macOS 14+, disables the default system focus ring on TextFields. /// On earlier macOS versions and on iOS this is a no-op. -private struct FocusEffectDisabledModifier: ViewModifier { +struct FocusEffectDisabledModifier: ViewModifier { func body(content: Content) -> some View { #if os(macOS) if #available(macOS 14.0, *) { @@ -33,49 +30,39 @@ private struct FocusEffectDisabledModifier: ViewModifier { } } -// MARK: - Main Content View - struct ContentView: View { - // MARK: - Properties - - @EnvironmentObject var viewModel: ChatViewModel + @EnvironmentObject private var appChromeModel: AppChromeModel + @EnvironmentObject private var privateConversationModel: PrivateConversationModel + @EnvironmentObject private var verificationModel: VerificationModel + @EnvironmentObject private var conversationUIModel: ConversationUIModel + @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() - @ObservedObject private var locationManager = LocationChannelManager.shared - @ObservedObject private var bookmarks = GeohashBookmarksStore.shared @State private var messageText = "" @FocusState private var isTextFieldFocused: Bool @Environment(\.colorScheme) var colorScheme - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var showSidebar = false - @State private var showAppInfo = false @State private var selectedMessageSender: String? @State private var selectedMessageSenderID: PeerID? @FocusState private var isNicknameFieldFocused: Bool - @State private var isAtBottomPublic: Bool = true - @State private var isAtBottomPrivate: Bool = true + @State private var isAtBottomPublic = true + @State private var isAtBottomPrivate = true @State private var autocompleteDebounceTimer: Timer? - @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false @State private var showLocationNotes = false - @State private var notesGeohash: String? = nil - @State private var imagePreviewURL: URL? = nil -#if os(iOS) + @State private var notesGeohash: String? + @State private var imagePreviewURL: URL? + #if os(iOS) @State private var showImagePicker = false @State private var imagePickerSourceType: UIImagePickerController.SourceType = .camera -#else + #else @State private var showMacImagePicker = false -#endif + #endif @ScaledMetric(relativeTo: .body) private var headerHeight: CGFloat = 44 @ScaledMetric(relativeTo: .subheadline) private var headerPeerIconSize: CGFloat = 11 @ScaledMetric(relativeTo: .subheadline) private var headerPeerCountFontSize: CGFloat = 12 - // Timer-based refresh removed; use LocationChannelManager live updates instead - // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [PeerID: Int] = [:] - - // MARK: - Computed Properties - + private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white } @@ -88,58 +75,37 @@ struct ContentView: View { colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) } - private var headerLineLimit: Int? { - dynamicTypeSize.isAccessibilitySize ? 2 : 1 + private var selectedPrivatePeerID: PeerID? { + privateConversationModel.selectedPeerID } - private var peopleSheetTitle: String { - String(localized: "content.header.people", comment: "Title for the people list sheet").lowercased() - } - - private var peopleSheetSubtitle: String? { - switch locationManager.selectedChannel { - case .mesh: - return "#mesh" - case .location(let channel): - return "#\(channel.geohash.lowercased())" - } - } - - private var peopleSheetActiveCount: Int { - switch locationManager.selectedChannel { - case .mesh: - return viewModel.allPeers.filter { $0.peerID != viewModel.meshService.myPeerID }.count - case .location: - return viewModel.visibleGeohashPeople().count - } - } - - - private struct PrivateHeaderContext { - let headerPeerID: PeerID - let peer: BitchatPeer? - let displayName: String - let isNostrAvailable: Bool - } - -// MARK: - Body - var body: some View { VStack(spacing: 0) { - mainHeaderView - .onAppear { - viewModel.currentColorScheme = colorScheme - #if os(macOS) - // Focus message input on macOS launch, not nickname field - DispatchQueue.main.async { - isNicknameFieldFocused = false - isTextFieldFocused = true - } - #endif - } - .onChange(of: colorScheme) { newValue in - viewModel.currentColorScheme = newValue + ContentHeaderView( + showSidebar: $showSidebar, + showVerifySheet: $showVerifySheet, + showLocationNotes: $showLocationNotes, + notesGeohash: $notesGeohash, + isNicknameFieldFocused: $isNicknameFieldFocused, + headerHeight: headerHeight, + headerPeerIconSize: headerPeerIconSize, + headerPeerCountFontSize: headerPeerCountFontSize, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor + ) + .onAppear { + conversationUIModel.setCurrentColorScheme(colorScheme) + #if os(macOS) + DispatchQueue.main.async { + isNicknameFieldFocused = false + isTextFieldFocused = true } + #endif + } + .onChange(of: colorScheme) { newValue in + conversationUIModel.setCurrentColorScheme(newValue) + } Divider() @@ -155,7 +121,7 @@ struct ContentView: View { windowCountPublic: $windowCountPublic, windowCountPrivate: $windowCountPrivate, showSidebar: $showSidebar, - isTextFieldFocused: $isTextFieldFocused, + isTextFieldFocused: $isTextFieldFocused ) .background(backgroundColor) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -165,8 +131,33 @@ struct ContentView: View { Divider() - if viewModel.selectedPrivateChatPeer == nil { - inputView + if selectedPrivatePeerID == nil { + #if os(iOS) + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onSendMessage: sendMessage, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentComposerView( + messageText: $messageText, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onSendMessage: sendMessage, + showMacImagePicker: $showMacImagePicker + ) + #endif } } .background(backgroundColor) @@ -174,44 +165,80 @@ struct ContentView: View { #if os(macOS) .frame(minWidth: 600, minHeight: 400) #endif - .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in + .onChange(of: selectedPrivatePeerID) { newValue in if newValue != nil { showSidebar = true } } .sheet( isPresented: Binding( - get: { showSidebar || viewModel.selectedPrivateChatPeer != nil }, + get: { showSidebar || selectedPrivatePeerID != nil }, set: { isPresented in if !isPresented { showSidebar = false - viewModel.endPrivateChat() + privateConversationModel.endConversation() } } ) ) { - peopleSheetView - .environmentObject(viewModel) + #if os(iOS) + ContentPeopleSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + headerHeight: headerHeight, + onSendMessage: sendMessage, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentPeopleSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: backgroundColor, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + headerHeight: headerHeight, + onSendMessage: sendMessage, + showMacImagePicker: $showMacImagePicker + ) + #endif } - .sheet(isPresented: $showAppInfo) { + .sheet(isPresented: $appChromeModel.isAppInfoPresented) { AppInfoView() - .environmentObject(viewModel) - .onAppear { viewModel.isAppInfoPresented = true } - .onDisappear { viewModel.isAppInfoPresented = false } } .sheet(isPresented: Binding( - get: { viewModel.showingFingerprintFor != nil && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, - set: { _ in viewModel.showingFingerprintFor = nil } + get: { appChromeModel.showingFingerprintFor != nil && !showSidebar && selectedPrivatePeerID == nil }, + set: { _ in appChromeModel.clearFingerprint() } )) { - if let peerID = viewModel.showingFingerprintFor { - FingerprintView(viewModel: viewModel, peerID: peerID) - .environmentObject(viewModel) + if let peerID = appChromeModel.showingFingerprintFor { + FingerprintView(peerID: peerID) + .environmentObject(verificationModel) } } -#if os(iOS) - // Only present image picker from main view when NOT in a sheet + #if os(iOS) .fullScreenCover(isPresented: Binding( - get: { showImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, + get: { showImagePicker && !showSidebar && selectedPrivatePeerID == nil }, set: { newValue in if !newValue { showImagePicker = false @@ -220,16 +247,14 @@ struct ContentView: View { )) { ImagePickerView(sourceType: imagePickerSourceType) { image in showImagePicker = false - viewModel.processThenSendImage(image) + conversationUIModel.processSelectedImage(image) } - .environmentObject(viewModel) .ignoresSafeArea() } -#endif -#if os(macOS) - // Only present Mac image picker from main view when NOT in a sheet + #endif + #if os(macOS) .sheet(isPresented: Binding( - get: { showMacImagePicker && !showSidebar && viewModel.selectedPrivateChatPeer == nil }, + get: { showMacImagePicker && !showSidebar && selectedPrivatePeerID == nil }, set: { newValue in if !newValue { showMacImagePicker = false @@ -238,18 +263,20 @@ struct ContentView: View { )) { MacImagePickerView { url in showMacImagePicker = false - viewModel.processThenSendImage(from: url) + conversationUIModel.processSelectedImage(from: url) } - .environmentObject(viewModel) } -#endif + #endif .sheet(isPresented: Binding( get: { imagePreviewURL != nil }, - set: { presenting in if !presenting { imagePreviewURL = nil } } + set: { presenting in + if !presenting { + imagePreviewURL = nil + } + } )) { if let url = imagePreviewURL { ImagePreviewView(url: url) - .environmentObject(viewModel) } } .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: { @@ -262,964 +289,26 @@ struct ContentView: View { }, message: { Text(voiceRecordingVM.state.alertMessage) }) - .alert("content.alert.bluetooth_required.title", isPresented: $viewModel.showBluetoothAlert) { + .alert("content.alert.bluetooth_required.title", isPresented: $appChromeModel.showBluetoothAlert) { Button("content.alert.bluetooth_required.settings") { SystemSettings.bluetooth.open() } Button("common.ok", role: .cancel) {} } message: { - Text(viewModel.bluetoothAlertMessage) + Text(appChromeModel.bluetoothAlertMessage) } .onDisappear { autocompleteDebounceTimer?.invalidate() } } - - // MARK: - Input View - @ViewBuilder - private var inputView: some View { - VStack(alignment: .leading, spacing: 6) { - // @mentions autocomplete - if viewModel.showAutocomplete && !viewModel.autocompleteSuggestions.isEmpty { - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(viewModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in - Button(action: { - _ = viewModel.completeNickname(suggestion, in: &messageText) - }) { - HStack { - Text(suggestion) - .font(.bitchatSystem(size: 11, design: .monospaced)) - .foregroundColor(textColor) - .fontWeight(.medium) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 3) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .background(Color.gray.opacity(0.1)) - } - } - .background(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(secondaryTextColor.opacity(0.3), lineWidth: 1) - ) - .padding(.horizontal, 12) - } - - CommandSuggestionsView( - messageText: $messageText, - textColor: textColor, - backgroundColor: backgroundColor, - secondaryTextColor: secondaryTextColor - ) - - if voiceRecordingVM.state.isActive { - recordingIndicator - } - - HStack(alignment: .center, spacing: 4) { - TextField( - "", - text: $messageText, - prompt: Text( - String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer") - ) - .foregroundColor(secondaryTextColor.opacity(0.6)) - ) - .textFieldStyle(.plain) - .font(.bitchatSystem(size: 15, design: .monospaced)) - .foregroundColor(textColor) - .focused($isTextFieldFocused) - .autocorrectionDisabled(true) -#if os(iOS) - .textInputAutocapitalization(.sentences) -#endif - .submitLabel(.send) - .onSubmit { sendMessage() } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7)) - ) - .modifier(FocusEffectDisabledModifier()) - .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: messageText) { newValue in - autocompleteDebounceTimer?.invalidate() - autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { [weak viewModel] _ in - let cursorPosition = newValue.count - Task { @MainActor in - viewModel?.updateAutocomplete(for: newValue, cursorPosition: cursorPosition) - } - } - } - - HStack(alignment: .center, spacing: 4) { - if shouldShowMediaControls { - attachmentButton - } - - sendOrMicButton - } - } - } - .padding(.horizontal, 6) - .padding(.top, 6) - .padding(.bottom, 8) - .background(backgroundColor.opacity(0.95)) - } - - // MARK: - Actions - private func sendMessage() { guard let trimmed = messageText.trimmedOrNilIfEmpty else { return } - // Clear input immediately for instant feedback messageText = "" - // Defer actual send to next runloop to avoid blocking DispatchQueue.main.async { - self.viewModel.sendMessage(trimmed) + self.conversationUIModel.sendMessage(trimmed) } } - - // MARK: - Sheet Content - - private var peopleSheetView: some View { - NavigationStack { - Group { - if viewModel.selectedPrivateChatPeer != nil { - privateChatSheetView - } else { - peopleListSheetView - } - } - .navigationDestination(isPresented: Binding( - get: { viewModel.showingFingerprintFor != nil && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, - set: { isPresented in - if !isPresented { - viewModel.showingFingerprintFor = nil - } - } - )) { - if let peerID = viewModel.showingFingerprintFor { - FingerprintView(viewModel: viewModel, peerID: peerID) - .environmentObject(viewModel) - } - } - } - .background(backgroundColor) - .foregroundColor(textColor) - #if os(macOS) - .frame(minWidth: 420, minHeight: 520) - #endif - // Present image picker from sheet context when IN a sheet (parent-child pattern) - #if os(iOS) - .fullScreenCover(isPresented: Binding( - get: { showImagePicker && (showSidebar || viewModel.selectedPrivateChatPeer != nil) }, - set: { newValue in - if !newValue { - showImagePicker = false - } - } - )) { - ImagePickerView(sourceType: imagePickerSourceType) { image in - showImagePicker = false - viewModel.processThenSendImage(image) - } - .environmentObject(viewModel) - .ignoresSafeArea() - } - #endif - #if os(macOS) - .sheet(isPresented: $showMacImagePicker) { - MacImagePickerView { url in - showMacImagePicker = false - viewModel.processThenSendImage(from: url) - } - .environmentObject(viewModel) - } - #endif - } - - // MARK: - People Sheet Views - - private var peopleListSheetView: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 12) { - Text(peopleSheetTitle) - .font(.bitchatSystem(size: 18, design: .monospaced)) - .foregroundColor(textColor) - Spacer() - if case .mesh = locationManager.selectedChannel { - Button(action: { showVerifySheet = true }) { - Image(systemName: "qrcode") - .font(.bitchatSystem(size: 14)) - } - .buttonStyle(.plain) - .help( - String(localized: "content.help.verification", comment: "Help text for verification button") - ) - } - Button(action: { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - dismiss() - showSidebar = false - showVerifySheet = false - viewModel.endPrivateChat() - } - }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - } - let activeText = String.localizedStringWithFormat( - String(localized: "%@ active", comment: "Count of active users in the people sheet"), - "\(peopleSheetActiveCount)" - ) - - if let subtitle = peopleSheetSubtitle { - let subtitleColor: Color = { - switch locationManager.selectedChannel { - case .mesh: - return Color.blue - case .location: - return Color.green - } - }() - HStack(spacing: 6) { - Text(subtitle) - .foregroundColor(subtitleColor) - Text(activeText) - .foregroundColor(.secondary) - } - .font(.bitchatSystem(size: 12, design: .monospaced)) - } else { - Text(activeText) - .font(.bitchatSystem(size: 12, design: .monospaced)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 12) - .background(backgroundColor) - - ScrollView { - VStack(alignment: .leading, spacing: 6) { - if case .location = locationManager.selectedChannel { - GeohashPeopleList( - viewModel: viewModel, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onTapPerson: { - showSidebar = true - } - ) - } else { - MeshPeerList( - viewModel: viewModel, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onTapPeer: { peerID in - viewModel.startPrivateChat(with: peerID) - showSidebar = true - }, - onToggleFavorite: { peerID in - viewModel.toggleFavorite(peerID: peerID) - }, - onShowFingerprint: { peerID in - viewModel.showFingerprint(for: peerID) - } - ) - } - } - .padding(.top, 4) - .id(viewModel.allPeers.map { "\($0.peerID)-\($0.isConnected)" }.joined()) - } - } - } - - // MARK: - View Components - - private var privateChatSheetView: some View { - VStack(spacing: 0) { - if let privatePeerID = viewModel.selectedPrivateChatPeer { - let headerContext = makePrivateHeaderContext(for: privatePeerID) - - HStack(spacing: 12) { - Button(action: { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - viewModel.endPrivateChat() - } - }) { - Image(systemName: "chevron.left") - .font(.bitchatSystem(size: 12)) - .foregroundColor(textColor) - .frame(width: 44, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.back_to_main_chat", comment: "Accessibility label for returning to main chat") - ) - - Spacer(minLength: 0) - - HStack(spacing: 8) { - privateHeaderInfo(context: headerContext, privatePeerID: privatePeerID) - let isFavorite = viewModel.isFavorite(peerID: headerContext.headerPeerID) - - if !privatePeerID.isGeoDM { - Button(action: { - viewModel.toggleFavorite(peerID: headerContext.headerPeerID) - }) { - Image(systemName: isFavorite ? "star.fill" : "star") - .font(.bitchatSystem(size: 14)) - .foregroundColor(isFavorite ? Color.yellow : textColor) - } - .buttonStyle(.plain) - .accessibilityLabel( - isFavorite - ? String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite") - : String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite") - ) - } - } - .frame(maxWidth: .infinity) - - Spacer(minLength: 0) - - Button(action: { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - viewModel.endPrivateChat() - showSidebar = true - } - }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced)) - .frame(width: 32, height: 32) - } - - .buttonStyle(.plain) - .accessibilityLabel("Close") - } - .frame(height: headerHeight) - .padding(.horizontal, 16) - .padding(.top, 10) - .padding(.bottom, 12) - .background(backgroundColor) - } - - MessageListView( - privatePeer: viewModel.selectedPrivateChatPeer, - isAtBottom: $isAtBottomPrivate, - messageText: $messageText, - selectedMessageSender: $selectedMessageSender, - selectedMessageSenderID: $selectedMessageSenderID, - imagePreviewURL: $imagePreviewURL, - windowCountPublic: $windowCountPublic, - windowCountPrivate: $windowCountPrivate, - showSidebar: $showSidebar, - isTextFieldFocused: $isTextFieldFocused, - ) - .background(backgroundColor) - .frame(maxWidth: .infinity, maxHeight: .infinity) - - Divider() - - inputView - } - .background(backgroundColor) - .foregroundColor(textColor) - .highPriorityGesture( - DragGesture(minimumDistance: 25, coordinateSpace: .local) - .onEnded { value in - let horizontal = value.translation.width - let vertical = abs(value.translation.height) - guard horizontal > 80, vertical < 60 else { return } - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar = true - viewModel.endPrivateChat() - } - } - ) - } - - private func privateHeaderInfo(context: PrivateHeaderContext, privatePeerID: PeerID) -> some View { - Button(action: { - viewModel.showFingerprint(for: context.headerPeerID) - }) { - HStack(spacing: 6) { - if let connectionState = context.peer?.connectionState { - switch connectionState { - case .bluetoothConnected: - Image(systemName: "dot.radiowaves.left.and.right") - .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - case .meshReachable: - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - case .nostrAvailable: - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - case .offline: - EmptyView() - } - } else if viewModel.meshService.isPeerReachable(context.headerPeerID) { - Image(systemName: "point.3.filled.connected.trianglepath.dotted") - .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) - .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")) - } else if context.isNostrAvailable { - Image(systemName: "globe") - .font(.bitchatSystem(size: 14)) - .foregroundColor(.purple) - .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) - } else if viewModel.meshService.isPeerConnected(context.headerPeerID) || viewModel.connectedPeers.contains(context.headerPeerID) { - Image(systemName: "dot.radiowaves.left.and.right") - .font(.bitchatSystem(size: 14)) - .foregroundColor(textColor) - .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")) - } - - Text(context.displayName) - .font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - - if !privatePeerID.isGeoDM { - let statusPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) - let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) - if let icon = encryptionStatus.icon { - Image(systemName: icon) - .font(.bitchatSystem(size: 14)) - .foregroundColor(encryptionStatus == .noiseVerified ? textColor : - encryptionStatus == .noiseSecured ? textColor : - Color.red) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.encryption_status", comment: "Accessibility label announcing encryption status"), - locale: .current, - encryptionStatus.accessibilityDescription - ) - ) - } - } - } - } - .buttonStyle(.plain) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.private_chat_header", comment: "Accessibility label describing the private chat header"), - locale: .current, - context.displayName - ) - ) - .accessibilityHint( - String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") - ) - .frame(height: headerHeight) - } - - private func makePrivateHeaderContext(for privatePeerID: PeerID) -> PrivateHeaderContext { - let headerPeerID = viewModel.getShortIDForNoiseKey(privatePeerID) - let peer = viewModel.getPeer(byID: headerPeerID) - - let displayName: String = { - if privatePeerID.isGeoDM, case .location(let ch) = locationManager.selectedChannel { - let disp = viewModel.geohashDisplayName(for: privatePeerID) - return "#\(ch.geohash)/@\(disp)" - } - if let name = peer?.displayName { return name } - if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } - if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID.id) ?? Data()), - !fav.peerNickname.isEmpty { return fav.peerNickname } - if headerPeerID.id.count == 16 { - let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) - if let id = candidates.first, - let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) { - if let pet = social.localPetname, !pet.isEmpty { return pet } - if !social.claimedNickname.isEmpty { return social.claimedNickname } - } - } else if let keyData = headerPeerID.noiseKey { - let fp = keyData.sha256Fingerprint() - if let social = viewModel.identityManager.getSocialIdentity(for: fp) { - if let pet = social.localPetname, !pet.isEmpty { return pet } - if !social.claimedNickname.isEmpty { return social.claimedNickname } - } - } - return String(localized: "common.unknown", comment: "Fallback label for unknown peer") - }() - - let isNostrAvailable: Bool = { - guard let connectionState = peer?.connectionState else { - if let noiseKey = Data(hexString: headerPeerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - favoriteStatus.isMutual { - return true - } - return false - } - return connectionState == .nostrAvailable - }() - - return PrivateHeaderContext( - headerPeerID: headerPeerID, - peer: peer, - displayName: displayName, - isNostrAvailable: isNostrAvailable - ) - } - - // Compute channel-aware people count and color for toolbar (cross-platform) - private func channelPeopleCountAndColor() -> (Int, Color) { - switch locationManager.selectedChannel { - case .location: - let n = viewModel.geohashPeople.count - let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) - return (n, n > 0 ? standardGreen : Color.secondary) - case .mesh: - let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in - guard peer.peerID != viewModel.meshService.myPeerID else { return } - if peer.isConnected { counts.mesh += 1; counts.others += 1 } - else if peer.isReachable { counts.others += 1 } - } - let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary - return (counts.others, color) - } - } - - - private var mainHeaderView: some View { - HStack(spacing: 0) { - Text(verbatim: "bitchat/") - .font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - .onTapGesture(count: 3) { - // PANIC: Triple-tap to clear all data - viewModel.panicClearAllData() - } - .onTapGesture(count: 1) { - // Single tap for app info - showAppInfo = true - } - - HStack(spacing: 0) { - Text(verbatim: "@") - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - - TextField("content.input.nickname_placeholder", text: $viewModel.nickname) - .textFieldStyle(.plain) - .font(.bitchatSystem(size: 14, design: .monospaced)) - .frame(maxWidth: 80) - .foregroundColor(textColor) - .focused($isNicknameFieldFocused) - .autocorrectionDisabled(true) - #if os(iOS) - .textInputAutocapitalization(.never) - #endif - .modifier(FocusEffectDisabledModifier()) - .onChange(of: isNicknameFieldFocused) { isFocused in - if !isFocused { - // Only validate when losing focus - viewModel.validateAndSaveNickname() - } - } - .onSubmit { - viewModel.validateAndSaveNickname() - } - } - - Spacer() - - // Channel badge + dynamic spacing + people counter - // Precompute header count and color outside the ViewBuilder expressions - let cc = channelPeopleCountAndColor() - let headerCountColor: Color = cc.1 - let headerOtherPeersCount: Int = { - if case .location = locationManager.selectedChannel { - return viewModel.visibleGeohashPeople().count - } - return cc.0 - }() - - HStack(spacing: 10) { - // Unread icon immediately to the left of the channel badge (independent from channel button) - - // Unread indicator (now shown on iOS and macOS) - if viewModel.hasAnyUnreadMessages { - Button(action: { viewModel.openMostRelevantPrivateChat() }) { - Image(systemName: "envelope.fill") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange) - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.open_unread_private_chat", comment: "Accessibility label for the unread private chat button") - ) - } - // Notes icon (mesh only and when location is authorized), to the left of #mesh - if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { - Button(action: { - // Kick a one-shot refresh and show the sheet immediately. - LocationChannelManager.shared.enableLocationChannels() - LocationChannelManager.shared.refreshChannels() - // If we already have a block geohash, pass it; otherwise wait in the sheet. - notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash - showLocationNotes = true - }) { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "note.text") - .font(.bitchatSystem(size: 12)) - .foregroundColor(Color.orange.opacity(0.8)) - .padding(.top, 1) - } - .fixedSize(horizontal: true, vertical: false) - } - .buttonStyle(.plain) - .accessibilityLabel( - String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button") - ) - } - - // Bookmark toggle (geochats): to the left of #geohash - if case .location(let ch) = locationManager.selectedChannel { - Button(action: { bookmarks.toggle(ch.geohash) }) { - Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark") - .font(.bitchatSystem(size: 12)) - } - .buttonStyle(.plain) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.toggle_bookmark", comment: "Accessibility label for toggling a geohash bookmark"), - locale: .current, - ch.geohash - ) - ) - } - - // Location channels button '#' - Button(action: { showLocationChannelsSheet = true }) { - let badgeText: String = { - switch locationManager.selectedChannel { - case .mesh: return "#mesh" - case .location(let ch): return "#\(ch.geohash)" - } - }() - let badgeColor: Color = { - switch locationManager.selectedChannel { - case .mesh: - return Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - case .location: - return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - }() - Text(badgeText) - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(badgeColor) - .lineLimit(headerLineLimit) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(2) - .accessibilityLabel( - String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button") - ) - } - .buttonStyle(.plain) - .padding(.leading, 4) - .padding(.trailing, 2) - - HStack(spacing: 4) { - // People icon with count - Image(systemName: "person.2.fill") - .font(.system(size: headerPeerIconSize, weight: .regular)) - .accessibilityLabel( - String( - format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"), - locale: .current, - headerOtherPeersCount - ) - ) - Text("\(headerOtherPeersCount)") - .font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced)) - .accessibilityHidden(true) - } - .foregroundColor(headerCountColor) - .padding(.leading, 2) - .lineLimit(headerLineLimit) - .fixedSize(horizontal: true, vertical: false) - - // QR moved to the PEOPLE header in the sidebar when on mesh channel - } - .layoutPriority(3) - .onTapGesture { - withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { - showSidebar.toggle() - } - } - .sheet(isPresented: $showVerifySheet) { - VerificationSheetView(isPresented: $showVerifySheet) - .environmentObject(viewModel) - } - } - .frame(height: headerHeight) - .padding(.horizontal, 12) - .sheet(isPresented: $showLocationChannelsSheet) { - LocationChannelsSheet(isPresented: $showLocationChannelsSheet) - .environmentObject(viewModel) - .onAppear { viewModel.isLocationChannelsSheetPresented = true } - .onDisappear { viewModel.isLocationChannelsSheetPresented = false } - } - .sheet(isPresented: $showLocationNotes, onDismiss: { - notesGeohash = nil - }) { - Group { - if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { - LocationNotesView(geohash: gh) - .environmentObject(viewModel) - } else { - VStack(spacing: 12) { - HStack { - Text("content.notes.title") - .font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced)) - Spacer() - Button(action: { showLocationNotes = false }) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons")) - } - .frame(height: headerHeight) - .padding(.horizontal, 12) - .background(backgroundColor.opacity(0.95)) - Text("content.notes.location_unavailable") - .font(.bitchatSystem(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - Button("content.location.enable") { - LocationChannelManager.shared.enableLocationChannels() - LocationChannelManager.shared.refreshChannels() - } - .buttonStyle(.bordered) - Spacer() - } - .background(backgroundColor) - .foregroundColor(textColor) - // per-sheet global onChange added below - } - } - .onAppear { - // Ensure we are authorized and start live location updates (distance-filtered) - LocationChannelManager.shared.enableLocationChannels() - LocationChannelManager.shared.beginLiveRefresh() - } - .onDisappear { - LocationChannelManager.shared.endLiveRefresh() - } - .onChange(of: locationManager.availableChannels) { channels in - if let current = channels.first(where: { $0.level == .building })?.geohash, - notesGeohash != current { - notesGeohash = current - #if os(iOS) - // Light taptic when geohash changes while the sheet is open - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - generator.impactOccurred() - #endif - } - } - } - .onAppear { - if case .mesh = locationManager.selectedChannel, - locationManager.permissionState == .authorized, - LocationChannelManager.shared.availableChannels.isEmpty { - LocationChannelManager.shared.refreshChannels() - } - } - .onChange(of: locationManager.selectedChannel) { _ in - if case .mesh = locationManager.selectedChannel, - locationManager.permissionState == .authorized, - LocationChannelManager.shared.availableChannels.isEmpty { - LocationChannelManager.shared.refreshChannels() - } - } - .onChange(of: locationManager.permissionState) { _ in - if case .mesh = locationManager.selectedChannel, - locationManager.permissionState == .authorized, - LocationChannelManager.shared.availableChannels.isEmpty { - LocationChannelManager.shared.refreshChannels() - } - } - .alert("content.alert.screenshot.title", isPresented: $viewModel.showScreenshotPrivacyWarning) { - Button("common.ok", role: .cancel) {} - } message: { - Text("content.alert.screenshot.message") - } - .background(backgroundColor.opacity(0.95)) - } - -} - -// MARK: - Helper Views - -private extension ContentView { - var recordingIndicator: some View { - HStack(spacing: 12) { - Image(systemName: "waveform.circle.fill") - .foregroundColor(.red) - .font(.bitchatSystem(size: 20)) - TimelineView(.periodic(from: .now, by: 0.05)) { context in - Text( - "recording \(voiceRecordingVM.formattedDuration(for: context.date))", - comment: "Voice note recording duration indicator" - ) - .font(.bitchatSystem(size: 13, design: .monospaced)) - .foregroundColor(.red) - } - Spacer() - Button(action: voiceRecordingVM.cancel) { - Label("Cancel", systemImage: "xmark.circle") - .labelStyle(.iconOnly) - .font(.bitchatSystem(size: 18)) - .foregroundColor(.red) - } - .buttonStyle(.plain) - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.red.opacity(0.15)) - ) - } - - private var shouldShowMediaControls: Bool { - if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { - return true - } - switch locationManager.selectedChannel { - case .mesh: - return true - case .location: - return false - } - } - - private var shouldShowVoiceControl: Bool { - if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) { - return true - } - switch locationManager.selectedChannel { - case .mesh: - return true - case .location: - return false - } - } - - private var composerAccentColor: Color { - viewModel.selectedPrivateChatPeer != nil ? Color.orange : textColor - } - - var attachmentButton: some View { - #if os(iOS) - Image(systemName: "camera.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(composerAccentColor) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .onTapGesture { - // Tap = Photo Library - imagePickerSourceType = .photoLibrary - showImagePicker = true - } - .onLongPressGesture(minimumDuration: 0.3) { - // Long press = Camera - imagePickerSourceType = .camera - showImagePicker = true - } - .accessibilityLabel("Tap for library, long press for camera") - #else - Button(action: { showMacImagePicker = true }) { - Image(systemName: "photo.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(composerAccentColor) - .frame(width: 36, height: 36) - } - .buttonStyle(.plain) - .accessibilityLabel("Choose photo") - #endif - } - - @ViewBuilder - var sendOrMicButton: some View { - let hasText = !messageText.trimmed.isEmpty - if shouldShowVoiceControl { - ZStack { - micButtonView - .opacity(hasText ? 0 : 1) - .allowsHitTesting(!hasText) - sendButtonView(enabled: hasText) - .opacity(hasText ? 1 : 0) - .allowsHitTesting(hasText) - } - .frame(width: 36, height: 36) - } else { - sendButtonView(enabled: hasText) - .frame(width: 36, height: 36) - } - } - - private var micButtonView: some View { - Image(systemName: "mic.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(voiceRecordingVM.state.isActive ? Color.red : composerAccentColor) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .overlay( - Color.clear - .contentShape(Circle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in voiceRecordingVM.start(shouldShow: shouldShowVoiceControl) } - .onEnded { _ in voiceRecordingVM.finish(completion: viewModel.sendVoiceNote) } - ) - ) - .accessibilityLabel("Hold to record a voice note") - } - - private func sendButtonView(enabled: Bool) -> some View { - let activeColor = composerAccentColor - return Button(action: sendMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.bitchatSystem(size: 24)) - .foregroundColor(enabled ? activeColor : Color.gray) - .frame(width: 36, height: 36) - } - .buttonStyle(.plain) - .disabled(!enabled) - .accessibilityLabel( - String(localized: "content.accessibility.send_message", comment: "Accessibility label for the send message button") - ) - .accessibilityHint( - enabled - ? String(localized: "content.accessibility.send_hint_ready", comment: "Hint prompting the user to send the message") - : String(localized: "content.accessibility.send_hint_empty", comment: "Hint prompting the user to enter a message") - ) - } } diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index 9c764e9e..d49007a1 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -10,7 +10,7 @@ import SwiftUI import BitFoundation struct FingerprintView: View { - @ObservedObject var viewModel: ChatViewModel + @EnvironmentObject private var verificationModel: VerificationModel let peerID: PeerID @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @@ -47,6 +47,8 @@ struct FingerprintView: View { } var body: some View { + let fingerprintState = verificationModel.fingerprintPresentation(for: peerID) + VStack(spacing: 20) { // Header HStack { @@ -65,38 +67,19 @@ struct FingerprintView: View { .padding() VStack(alignment: .leading, spacing: 16) { - // Prefer short mesh ID for session/encryption status - let statusPeerID = viewModel.getShortIDForNoiseKey(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 let data = peerID.noiseKey { - if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } - let fp = data.sha256Fingerprint() - if let social = viewModel.identityManager.getSocialIdentity(for: fp) { - if let pet = social.localPetname, !pet.isEmpty { return pet } - if !social.claimedNickname.isEmpty { return social.claimedNickname } - } - } - return Strings.unknownPeer() - }() - // Accurate encryption state based on short ID session - let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) - HStack { - if let icon = encryptionStatus.icon { + if let icon = fingerprintState.encryptionStatus.icon { Image(systemName: icon) .font(.bitchatSystem(size: 20)) - .foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor) + .foregroundColor(fingerprintState.encryptionStatus == .noiseVerified ? Color.green : textColor) } VStack(alignment: .leading, spacing: 4) { - Text(peerNickname) + Text(fingerprintState.peerNickname) .font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced)) .foregroundColor(textColor) - Text(encryptionStatus.description) + Text(fingerprintState.encryptionStatus.description) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) } @@ -113,7 +96,7 @@ struct FingerprintView: View { .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) - if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { + if let fingerprint = fingerprintState.theirFingerprint { Text(formatFingerprint(fingerprint)) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) @@ -148,8 +131,7 @@ struct FingerprintView: View { .font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) - let myFingerprint = viewModel.getMyFingerprint() - Text(formatFingerprint(myFingerprint)) + Text(formatFingerprint(fingerprintState.myFingerprint)) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(textColor) .multilineTextAlignment(.leading) @@ -162,30 +144,28 @@ struct FingerprintView: View { .contextMenu { Button(Strings.copy) { #if os(iOS) - UIPasteboard.general.string = myFingerprint + UIPasteboard.general.string = fingerprintState.myFingerprint #else NSPasteboard.general.clearContents() - NSPasteboard.general.setString(myFingerprint, forType: .string) + NSPasteboard.general.setString(fingerprintState.myFingerprint, forType: .string) #endif } } } // Verification status - if encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified { - let isVerified = encryptionStatus == .noiseVerified - + if fingerprintState.canToggleVerification { VStack(spacing: 12) { - Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) + Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge) .font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced)) - .foregroundColor(isVerified ? Color.green : Color.orange) + .foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange) .frame(maxWidth: .infinity) Group { - if isVerified { + if fingerprintState.isVerified { Text(Strings.verifiedMessage) } else { - Text(Strings.verifyHint(peerNickname)) + Text(Strings.verifyHint(fingerprintState.peerNickname)) } } .font(.bitchatSystem(size: 12, design: .monospaced)) @@ -195,9 +175,9 @@ struct FingerprintView: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity) - if !isVerified { + if !fingerprintState.isVerified { Button(action: { - viewModel.verifyFingerprint(for: peerID) + verificationModel.verifyFingerprint(for: peerID) dismiss() }) { Text(Strings.markVerified) @@ -211,7 +191,7 @@ struct FingerprintView: View { .buttonStyle(PlainButtonStyle()) } else { Button(action: { - viewModel.unverifyFingerprint(for: peerID) + verificationModel.unverifyFingerprint(for: peerID) dismiss() }) { Text(Strings.removeVerification) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index dabdcc02..4a3f0f12 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -1,7 +1,7 @@ import SwiftUI struct GeohashPeopleList: View { - @ObservedObject var viewModel: ChatViewModel + @EnvironmentObject private var peerListModel: PeerListModel let textColor: Color let secondaryTextColor: Color let onTapPerson: () -> Void @@ -17,7 +17,7 @@ struct GeohashPeopleList: View { } var body: some View { - if viewModel.visibleGeohashPeople().isEmpty { + if peerListModel.geohashPeople.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) .font(.bitchatSystem(size: 14, design: .monospaced)) @@ -26,26 +26,16 @@ struct GeohashPeopleList: View { .padding(.top, 12) } } else { - let myHex: String? = { - if case .location(let ch) = LocationChannelManager.shared.selectedChannel, - let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) { - return id.publicKeyHex.lowercased() - } - return nil - }() - let people = viewModel.visibleGeohashPeople() - let currentIDs = people.map { $0.id } - - let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) - let isTeleportedID: (String) -> Bool = { id in - if teleportedSet.contains(id.lowercased()) { return true } - if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } - return false - } + let people = peerListModel.geohashPeople + let currentIDs = people.map(\.id) let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } - let nonTele = displayIDs.filter { !isTeleportedID($0) } - let tele = displayIDs.filter { isTeleportedID($0) } + let nonTele = displayIDs.filter { id in + !(people.first(where: { $0.id == id })?.isTeleported ?? false) + } + let tele = displayIDs.filter { id in + people.first(where: { $0.id == id })?.isTeleported ?? false + } let finalOrder: [String] = nonTele + tele let firstID = finalOrder.first let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) }) @@ -54,38 +44,34 @@ struct GeohashPeopleList: View { ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in let person = personByID[pid]! HStack(spacing: 4) { - let isMe = (person.id == myHex) - let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) - let icon = teleported ? "face.dashed" : "mappin.and.ellipse" - let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) - let rowColor: Color = isMe ? .orange : assignedColor + let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" + let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) + let rowColor: Color = person.isMe ? .orange : assignedColor Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor) let (base, suffix) = person.displayName.splitSuffix() HStack(spacing: 0) { Text(base) .font(.bitchatSystem(size: 14, design: .monospaced)) - .fontWeight(isMe ? .bold : .regular) + .fontWeight(person.isMe ? .bold : .regular) .foregroundColor(rowColor) if !suffix.isEmpty { - let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) + let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) Text(suffix) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(suffixColor) } - if isMe { + if person.isMe { Text(Strings.youSuffix) .font(.bitchatSystem(size: 14, design: .monospaced)) .foregroundColor(rowColor) } } - if let me = myHex, person.id != me { - if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { - Image(systemName: "nosign") - .font(.bitchatSystem(size: 10)) - .foregroundColor(.red) - .help(Strings.blockedTooltip) - } + if person.isBlocked { + Image(systemName: "nosign") + .font(.bitchatSystem(size: 10)) + .foregroundColor(.red) + .help(Strings.blockedTooltip) } Spacer() } @@ -94,20 +80,29 @@ struct GeohashPeopleList: View { .padding(.top, person.id == firstID ? 10 : 0) .contentShape(Rectangle()) .onTapGesture { - if person.id != myHex { - viewModel.startGeohashDM(withPubkeyHex: person.id) + if !person.isMe { + peerListModel.openGeohashDirectMessage(with: person.id) onTapPerson() } } .contextMenu { - if let me = myHex, person.id == me { + if person.isMe { EmptyView() } else { - let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) - if blocked { - Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } + if person.isBlocked { + Button(Strings.unblock) { + peerListModel.unblockGeohashUser( + pubkeyHexLowercased: person.id, + displayName: person.displayName + ) + } } else { - Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } + Button(Strings.block) { + peerListModel.blockGeohashUser( + pubkeyHexLowercased: person.id, + displayName: person.displayName + ) + } } } } diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index a54fb32e..a4046c42 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -7,10 +7,8 @@ import AppKit #endif struct LocationChannelsSheet: View { @Binding var isPresented: Bool - @ObservedObject private var manager = LocationChannelManager.shared - @ObservedObject private var bookmarks = GeohashBookmarksStore.shared - @ObservedObject private var network = NetworkActivationService.shared - @EnvironmentObject var viewModel: ChatViewModel + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel + @EnvironmentObject private var peerListModel: PeerListModel @Environment(\.colorScheme) var colorScheme @State private var customGeohash: String = "" @State private var customError: String? = nil @@ -108,9 +106,9 @@ struct LocationChannelsSheet: View { .foregroundColor(.secondary) Group { - switch manager.permissionState { - case LocationChannelManager.PermissionState.notDetermined: - Button(action: { manager.enableLocationChannels() }) { + switch locationChannelsModel.permissionState { + case .notDetermined: + Button(action: { locationChannelsModel.enableLocationChannels() }) { Text(Strings.requestPermissions) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(standardGreen) @@ -120,7 +118,7 @@ struct LocationChannelsSheet: View { .cornerRadius(6) } .buttonStyle(.plain) - case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted: + case .denied, .restricted: VStack(alignment: .leading, spacing: 8) { Text(Strings.permissionDenied) .font(.bitchatSystem(size: 12, design: .monospaced)) @@ -128,7 +126,7 @@ struct LocationChannelsSheet: View { Button(Strings.openSettings, action: SystemSettings.location.open) .buttonStyle(.plain) } - case LocationChannelManager.PermissionState.authorized: + case .authorized: EmptyView() } } @@ -152,22 +150,22 @@ struct LocationChannelsSheet: View { .background(backgroundColor) .onAppear { // Refresh channels when opening - if manager.permissionState == LocationChannelManager.PermissionState.authorized { - manager.refreshChannels() + if locationChannelsModel.permissionState == .authorized { + locationChannelsModel.refreshChannels() } // Begin periodic refresh while sheet is open - manager.beginLiveRefresh() + locationChannelsModel.beginLiveRefresh() // Geohash sampling is now managed by ChatViewModel globally } .onDisappear { - manager.endLiveRefresh() + locationChannelsModel.endLiveRefresh() } - .onChange(of: manager.permissionState) { newValue in - if newValue == LocationChannelManager.PermissionState.authorized { - manager.refreshChannels() + .onChange(of: locationChannelsModel.permissionState) { newValue in + if newValue == .authorized { + locationChannelsModel.refreshChannels() } } - .onChange(of: manager.availableChannels) { _ in } + .onChange(of: locationChannelsModel.availableChannels) { _ in } } private var closeButton: some View { @@ -183,20 +181,20 @@ struct LocationChannelsSheet: View { private var channelList: some View { ScrollView { LazyVStack(spacing: 0) { - channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { - manager.select(ChannelID.mesh) + channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) { + locationChannelsModel.select(ChannelID.mesh) isPresented = false } .padding(.vertical, 6) - let nearby = manager.availableChannels.filter { $0.level != .building } + let nearby = locationChannelsModel.availableChannels.filter { $0.level != .building } if !nearby.isEmpty { ForEach(nearby) { channel in sectionDivider let coverage = coverageString(forPrecision: channel.geohash.count) let nameBase = locationName(for: channel.level) let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } - let participantCount = viewModel.geohashParticipantCount(for: channel.geohash) + let participantCount = peerListModel.participantCount(for: channel.geohash) let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage) let highlight = participantCount > 0 channelRow( @@ -206,16 +204,16 @@ struct LocationChannelsSheet: View { isSelected: isSelected(channel), titleBold: highlight, trailingAccessory: { - Button(action: { bookmarks.toggle(channel.geohash) }) { - Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") + Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) { + Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) } ) { - manager.markTeleported(for: channel.geohash, false) - manager.select(ChannelID.location(channel)) + locationChannelsModel.markTeleported(for: channel.geohash, false) + locationChannelsModel.select(ChannelID.location(channel)) isPresented = false } .padding(.vertical, 6) @@ -235,14 +233,14 @@ struct LocationChannelsSheet: View { customTeleportSection .padding(.vertical, 8) - let bookmarkedList = bookmarks.bookmarks + let bookmarkedList = locationChannelsModel.bookmarks if !bookmarkedList.isEmpty { sectionDivider bookmarkedSection(bookmarkedList) .padding(.vertical, 8) } - if manager.permissionState == LocationChannelManager.PermissionState.authorized { + if locationChannelsModel.permissionState == .authorized { sectionDivider torToggleSection .padding(.top, 12) @@ -309,10 +307,7 @@ struct LocationChannelsSheet: View { Button(action: { let gh = normalized guard isValid else { customError = Strings.invalidGeohash; return } - let level = levelForLength(gh.count) - let ch = GeohashChannel(level: level, geohash: gh) - manager.markTeleported(for: ch.geohash, true) - manager.select(ChannelID.location(ch)) + locationChannelsModel.teleport(to: gh) isPresented = false }) { HStack(spacing: 6) { @@ -350,33 +345,33 @@ struct LocationChannelsSheet: View { let channel = GeohashChannel(level: level, geohash: gh) let coverage = coverageString(forPrecision: gh.count) let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage) - let name = bookmarks.bookmarkNames[gh] - let participantCount = viewModel.geohashParticipantCount(for: gh) + let name = locationChannelsModel.bookmarkNames[gh] + let participantCount = peerListModel.participantCount(for: gh) channelRow( title: Strings.bookmarkTitle(geohash: gh, count: participantCount), subtitlePrefix: subtitle, subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, - isSelected: isSelected(channel), + isSelected: locationChannelsModel.isSelected(channel), trailingAccessory: { - Button(action: { bookmarks.toggle(gh) }) { - Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") + Button(action: { locationChannelsModel.toggleBookmark(gh) }) { + Image(systemName: locationChannelsModel.isBookmarked(gh) ? "bookmark.fill" : "bookmark") .font(.bitchatSystem(size: 14)) } .buttonStyle(.plain) .padding(.leading, 8) } ) { - let inRegional = manager.availableChannels.contains { $0.geohash == gh } - if !inRegional && !manager.availableChannels.isEmpty { - manager.markTeleported(for: gh, true) + let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh } + if !inRegional && !locationChannelsModel.availableChannels.isEmpty { + locationChannelsModel.markTeleported(for: gh, true) } else { - manager.markTeleported(for: gh, false) + locationChannelsModel.markTeleported(for: gh, false) } - manager.select(ChannelID.location(channel)) + locationChannelsModel.select(ChannelID.location(channel)) isPresented = false } .padding(.vertical, 6) - .onAppear { bookmarks.resolveBookmarkNameIfNeeded(for: gh) } + .onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) } if index < entries.count - 1 { sectionDivider @@ -388,14 +383,11 @@ struct LocationChannelsSheet: View { private func isSelected(_ channel: GeohashChannel) -> Bool { - if case .location(let ch) = manager.selectedChannel { - return ch == channel - } - return false + locationChannelsModel.isSelected(channel) } private var isMeshSelected: Bool { - if case .mesh = manager.selectedChannel { return true } + if case .mesh = locationChannelsModel.selectedChannel { return true } return false } @@ -453,17 +445,6 @@ struct LocationChannelsSheet: View { let suffix = String(s[idx...]) return (prefix, suffix) } - - // MARK: - Helpers for counts - private func meshCount() -> Int { - // Count mesh-connected OR mesh-reachable peers (exclude self) - let myID = viewModel.meshService.myPeerID - return viewModel.allPeers.reduce(0) { acc, peer in - if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 } - return acc - } - } - private func validateGeohash(_ s: String) -> Bool { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard !s.isEmpty, s.count <= 12 else { return false } @@ -487,8 +468,8 @@ struct LocationChannelsSheet: View { extension LocationChannelsSheet { private var torToggleBinding: Binding { Binding( - get: { network.userTorEnabled }, - set: { network.setUserTorEnabled($0) } + get: { locationChannelsModel.userTorEnabled }, + set: { locationChannelsModel.setUserTorEnabled($0) } ) } @@ -608,7 +589,7 @@ extension LocationChannelsSheet { } private func locationName(for level: GeohashChannelLevel) -> String? { - manager.locationNames[level] + locationChannelsModel.locationName(for: level) } private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index 4d302524..4124a87d 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -1,24 +1,26 @@ import SwiftUI struct LocationNotesView: View { - @EnvironmentObject var viewModel: ChatViewModel @StateObject private var manager: LocationNotesManager let geohash: String + let senderNickname: String let onNotesCountChanged: ((Int) -> Void)? @Environment(\.colorScheme) var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @ObservedObject private var locationManager = LocationChannelManager.shared + @EnvironmentObject private var locationChannelsModel: LocationChannelsModel @Environment(\.dismiss) private var dismiss @State private var draft: String = "" init( geohash: String, + senderNickname: String, onNotesCountChanged: ((Int) -> Void)? = nil, manager: LocationNotesManager? = nil ) { let gh = geohash.lowercased() self.geohash = gh + self.senderNickname = senderNickname self.onNotesCountChanged = onNotesCountChanged _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) } @@ -113,11 +115,11 @@ struct LocationNotesView: View { Spacer() closeButton } - if let building = locationManager.locationNames[.building], !building.isEmpty { + if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty { Text(building) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(accentGreen) - } else if let block = locationManager.locationNames[.block], !block.isEmpty { + } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty { Text(block) .font(.bitchatSystem(size: 12, design: .monospaced)) .foregroundColor(accentGreen) @@ -265,7 +267,7 @@ struct LocationNotesView: View { private func send() { guard let content = draft.trimmedOrNilIfEmpty else { return } - manager.send(content: content, nickname: viewModel.nickname) + manager.send(content: content, nickname: senderNickname) draft = "" } diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 0673e522..cc88fd03 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -10,8 +10,7 @@ import BitFoundation struct MediaMessageView: View { @Environment(\.colorScheme) private var colorScheme - - @EnvironmentObject var viewModel: ChatViewModel + @EnvironmentObject private var conversationUIModel: ConversationUIModel let message: BitchatMessage let media: BitchatMessage.Media @@ -19,15 +18,15 @@ struct MediaMessageView: View { var body: some View { let state = mediaSendState(for: message) - let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID - let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil + let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) + let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 4) { - Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - if message.isPrivate && message.sender == viewModel.nickname, + if message.isPrivate && conversationUIModel.isSentByCurrentUser(message), let status = message.deliveryStatus { DeliveryStatusView(status: status) .padding(.leading, 4) @@ -55,7 +54,7 @@ struct MediaMessageView: View { imagePreviewURL = url } }, - onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil + onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil ) .frame(maxWidth: 280) } @@ -81,7 +80,7 @@ struct MediaMessageView: View { break } } - let canCancel = isSending && message.sender == viewModel.nickname + let canCancel = isSending && conversationUIModel.isSentByCurrentUser(message) let clamped = progress.map { max(0, min(1, $0)) } return (isSending, isSending ? clamped : nil, canCancel) } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 4c785f5e..5a838230 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -2,7 +2,7 @@ import SwiftUI import BitFoundation struct MeshPeerList: View { - @ObservedObject var viewModel: ChatViewModel + @EnvironmentObject private var peerListModel: PeerListModel let textColor: Color let secondaryTextColor: Color let onTapPeer: (PeerID) -> Void @@ -19,21 +19,13 @@ 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) - let enc = viewModel.getEncryptionStatus(for: peer.peerID) - return (peer, isMe, hasUnread, enc) - } - // Stable visual order without mutating state here - let currentIDs = mapped.map { $0.peer.peerID.id } + let currentIDs = peerListModel.meshRows.map(\.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 }) + let peers: [MeshPeerRow] = displayIDs.compactMap { id in + peerListModel.meshRows.first(where: { $0.id == id }) } - - if viewModel.allPeers.isEmpty { + + if peerListModel.meshRows.isEmpty { VStack(alignment: .leading, spacing: 0) { Text(Strings.noneNearby) .font(.bitchatSystem(size: 14, design: .monospaced)) @@ -44,11 +36,10 @@ struct MeshPeerList: View { } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. some View { - Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) + Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } @@ -246,7 +236,7 @@ private extension MessageListView { if let peer = privatePeer { "dm:\(peer)" } else { - locationManager.selectedChannel.contextKey + locationChannelsModel.selectedChannel.contextKey } }() let preserveID = "\(contextKey)|\(message.id)" @@ -278,15 +268,12 @@ private extension MessageListView { let peerID = PeerID(str: id.removingPercentEncoding ?? id) selectedMessageSenderID = peerID - if peerID.isGeoDM || peerID.isGeoChat { - selectedMessageSender = viewModel.geohashDisplayName(for: peerID) - } else if let name = viewModel.meshService.peerNickname(peerID: peerID) { - selectedMessageSender = name - } else { - selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender - } + selectedMessageSender = conversationUIModel.senderDisplayName( + for: peerID, + fallbackMessages: conversationMessages(for: privatePeer) + ) - if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) { + if conversationUIModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) { selectedMessageSender = nil selectedMessageSenderID = nil } else { @@ -297,26 +284,7 @@ private extension MessageListView { let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } - - func levelForLength(_ len: Int) -> GeohashChannelLevel { - switch len { - case 0...2: return .region - case 3...4: return .province - case 5: return .city - case 6: return .neighborhood - case 7: return .block - default: return .block - } - } - - let level = levelForLength(gh.count) - let channel = GeohashChannel(level: level, geohash: gh) - - let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh } - if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty { - LocationChannelManager.shared.markTeleported(for: gh, true) - } - LocationChannelManager.shared.select(ChannelID.location(channel)) + locationChannelsModel.openLocationChannel(for: gh) default: return @@ -337,20 +305,21 @@ private extension MessageListView { var targetPeerID: String? { if let peer = privatePeer, - let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { + let last = privateInboxModel.messages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } - if let last = viewModel.messages.suffix(300).last?.id { - return "\(locationManager.selectedChannel.contextKey)|\(last)" + if let last = publicChatModel.messages.suffix(300).last?.id { + return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)" } return nil } func onMessagesChange(proxy: ScrollViewProxy) { - guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return } + let messages = publicChatModel.messages + guard privatePeer == nil, let lastMsg = messages.last else { return } // If the newest message is from me, always scroll to bottom - let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") + let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom return } else { // Ensure we consider ourselves at bottom for subsequent messages @@ -359,8 +328,8 @@ private extension MessageListView { func scrollIfNeeded(date: Date) { lastScrollTime = date - let contextKey = locationManager.selectedChannel.contextKey - if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) { + let contextKey = locationChannelsModel.selectedChannel.contextKey + if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) { proxy.scrollTo(target, anchor: .bottom) } } @@ -382,12 +351,14 @@ private extension MessageListView { } func onPrivateChatsChange(proxy: ScrollViewProxy) { - guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else { + guard let peerID = privatePeer, + let lastMsg = privateInboxModel.messages(for: peerID).last else { return } + let messages = privateInboxModel.messages(for: peerID) // If the newest private message is from me, always scroll - let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#") + let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom return } else { @@ -428,11 +399,22 @@ private extension MessageListView { isAtBottom = true windowCountPublic = TransportConfig.uiWindowInitialCountPublic let contextKey = "geo:\(ch.geohash)" - if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) { + if let target = publicChatModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) { proxy.scrollTo(target, anchor: .bottom) } } } + + func conversationMessages(for privatePeer: PeerID?) -> [BitchatMessage] { + if let privatePeer { + return privateInboxModel.messages(for: privatePeer) + } + return publicChatModel.messages + } + + func privateMessageCount(for privatePeer: PeerID?) -> Int { + conversationMessages(for: privatePeer).count + } } private extension ChannelID { diff --git a/bitchat/Views/VerificationViews.swift b/bitchat/Views/VerificationViews.swift index 180e2b46..acd2402b 100644 --- a/bitchat/Views/VerificationViews.swift +++ b/bitchat/Views/VerificationViews.swift @@ -107,7 +107,7 @@ struct ImageWrapper: View { /// Placeholder scanner UI; real camera scanning will be added later. struct QRScanView: View { - @EnvironmentObject var viewModel: ChatViewModel + @EnvironmentObject private var verificationModel: VerificationModel var isActive: Bool = true var onSuccess: (() -> Void)? = nil // Called when verification succeeds @State private var input = "" @@ -135,17 +135,15 @@ struct QRScanView: View { // Deduplicate: ignore if we just processed this exact QR code guard code != lastValid else { return } - if let qr = VerificationService.shared.verifyScannedQR(code) { - let ok = viewModel.beginQRVerification(with: qr) - if ok { - // Successfully initiated verification; remember this QR to prevent re-scanning - lastValid = code - // Close scanner and return to "My QR" view - onSuccess?() - } - // If !ok, peer not found or already pending - don't set lastValid so user can retry - } else { - // ignore invalid reads; continue scanning + switch verificationModel.verifyScannedPayload(code) { + case .requested: + // Successfully initiated verification; remember this QR to prevent re-scanning + lastValid = code + // Close scanner and return to "My QR" view + onSuccess?() + case .notFound, .invalid: + // Ignore invalid/no-match reads and keep scanning + break } } .frame(height: 260) @@ -163,17 +161,15 @@ struct QRScanView: View { return } - if let qr = VerificationService.shared.verifyScannedQR(input) { - let ok = viewModel.beginQRVerification(with: qr) - if ok { - result = Strings.requested(qr.nickname) - lastValid = input - // Close scanner and return to "My QR" view - onSuccess?() - } else { - result = Strings.notFound - } - } else { + switch verificationModel.verifyScannedPayload(input) { + case .requested(let nickname): + result = Strings.requested(nickname) + lastValid = input + // Close scanner and return to "My QR" view + onSuccess?() + case .notFound: + result = Strings.notFound + case .invalid: result = Strings.invalid } } @@ -282,7 +278,7 @@ struct CameraScannerView: UIViewRepresentable { // Combined sheet: shows my QR by default with a button to scan instead struct VerificationSheetView: View { - @EnvironmentObject var viewModel: ChatViewModel + @EnvironmentObject private var verificationModel: VerificationModel @Binding var isPresented: Bool @State private var showingScanner = false @Environment(\.colorScheme) var colorScheme @@ -291,11 +287,6 @@ struct VerificationSheetView: View { private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } private var boxColor: Color { Color.gray.opacity(0.1) } - private func myQRString() -> String { - let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub - return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? "" - } - var body: some View { VStack(spacing: 0) { // Top header (always at top) @@ -333,14 +324,14 @@ struct VerificationSheetView: View { QRScanView(isActive: showingScanner, onSuccess: { showingScanner = false }) - .environmentObject(viewModel) + .environmentObject(verificationModel) .frame(height: 280) .clipShape(RoundedRectangle(cornerRadius: 10)) #else QRScanView(onSuccess: { showingScanner = false }) - .environmentObject(viewModel) + .environmentObject(verificationModel) #endif } .padding() @@ -348,8 +339,7 @@ struct VerificationSheetView: View { .background(boxColor) .cornerRadius(8) } else { - let qr = myQRString() - MyQRView(qrString: qr) + MyQRView(qrString: verificationModel.myQRString()) } } .padding(16) @@ -373,10 +363,9 @@ struct VerificationSheetView: View { } // Optional: Remove verification for selected peer (if verified) - if let pid = viewModel.selectedPrivateChatPeer, - let fp = viewModel.getFingerprint(for: pid), - viewModel.verifiedFingerprints.contains(fp) { - Button(action: { viewModel.unverifyFingerprint(for: pid) }) { + if let peerID = verificationModel.selectedPeerID, + verificationModel.isVerified(peerID: peerID) { + Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) { Label("remove verification", systemImage: "minus.circle") .font(.bitchatSystem(size: 12, design: .monospaced)) } diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift new file mode 100644 index 00000000..c58a6fd4 --- /dev/null +++ b/bitchatTests/AppArchitectureTests.swift @@ -0,0 +1,699 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +@MainActor +private func makeArchitectureViewModel( + locationManager: LocationChannelManager? = nil +) -> ChatViewModel { + let keychain = MockKeychain() + let keychainHelper = MockKeychainHelper() + let idBridge = NostrIdentityBridge(keychain: keychainHelper) + let identityManager = MockIdentityManager(keychain) + let locationManager = locationManager ?? makeArchitectureLocationManager() + + return ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + transport: MockTransport(), + locationManager: locationManager + ) +} + +@MainActor +private func makeArchitectureLocationManager() -> LocationChannelManager { + let suiteName = "AppArchitectureTests.\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName) ?? .standard + storage.removePersistentDomain(forName: suiteName) + return LocationChannelManager(storage: storage) +} + +private func makeArchitectureSnapshot( + peerID: PeerID, + nickname: String, + connected: Bool, + noisePublicKey: Data +) -> TransportPeerSnapshot { + TransportPeerSnapshot( + peerID: peerID, + nickname: nickname, + isConnected: connected, + noisePublicKey: noisePublicKey, + lastSeen: Date() + ) +} + +@MainActor +private func waitUntil( + timeoutNanoseconds: UInt64 = 3_000_000_000, + pollNanoseconds: UInt64 = 20_000_000, + _ condition: @escaping @MainActor () -> Bool +) async { + let timeout = Double(timeoutNanoseconds) / 1_000_000_000 + let deadline = Date().addingTimeInterval(timeout) + + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: pollNanoseconds) + } +} + +@Suite("App Architecture Tests", .serialized) +struct AppArchitectureTests { + + @Test("PeerIdentityStore owns fingerprint, mapping, and verification state") + @MainActor + func peerIdentityStoreOwnsIdentityState() { + let store = PeerIdentityStore() + let shortPeerID = PeerID(str: "peer-short") + let stablePeerID = PeerID(str: "peer-stable") + let canonicalPeerID = PeerID(str: "peer-canonical") + + store.setStablePeerID(stablePeerID, forShortID: shortPeerID) + store.setFingerprint("fp-1", for: shortPeerID) + store.setCachedEncryptionStatus(.noiseHandshaking, for: shortPeerID) + store.setEncryptionStatus(.noiseSecured, for: shortPeerID) + store.setVerified("fp-1", verified: true) + + let migratedFingerprint = store.migrateFingerprintMapping( + from: shortPeerID, + to: canonicalPeerID + ) + + #expect(store.stablePeerID(forShortID: shortPeerID) == stablePeerID) + #expect(store.shortPeerID(forStablePeerID: stablePeerID) == shortPeerID) + #expect(migratedFingerprint == "fp-1") + #expect(store.fingerprint(for: shortPeerID) == nil) + #expect(store.fingerprint(for: canonicalPeerID) == "fp-1") + #expect(store.selectedPrivateChatFingerprint == "fp-1") + #expect(store.encryptionStatus(for: shortPeerID) == .noiseSecured) + #expect(store.cachedEncryptionStatus(for: shortPeerID) == nil) + #expect(store.isVerified("fp-1")) + + store.clearAll() + + #expect(store.encryptionStatuses.isEmpty) + #expect(store.verifiedFingerprints.isEmpty) + #expect(store.peerFingerprintsByPeerID.isEmpty) + #expect(store.selectedPrivateChatFingerprint == nil) + #expect(store.stablePeerID(forShortID: shortPeerID) == nil) + } + + @Test("LocationPresenceStore normalizes and resets geohash presence state") + @MainActor + func locationPresenceStoreNormalizesPresenceState() { + let store = LocationPresenceStore() + + store.setCurrentGeohash("U4PRUY") + store.replaceGeoNicknames([ + "ABCDEF": "alice", + "123456": "bob" + ]) + store.markTeleported("ABCDEF") + store.replaceTeleportedGeo(Set(["FEDCBA", "123456"])) + + #expect(store.currentGeohash == "u4pruy") + #expect(store.geoNicknames["abcdef"] == "alice") + #expect(store.geoNicknames["123456"] == "bob") + #expect(store.teleportedGeo == Set(["fedcba", "123456"])) + + store.reset() + + #expect(store.currentGeohash == nil) + #expect(store.geoNicknames.isEmpty) + #expect(store.teleportedGeo.isEmpty) + } + + @Test("PeerHandle equality and hashing use the canonical identity only") + func peerHandleEqualityUsesCanonicalIdentity() { + let first = PeerHandle( + id: "noise:abc123", + routingPeerID: PeerID(str: "peer-a"), + displayName: "alice", + noisePublicKeyHex: "abc123", + nostrPublicKey: nil + ) + let second = PeerHandle( + id: "noise:abc123", + routingPeerID: PeerID(str: "peer-b"), + displayName: "alice-renamed", + noisePublicKeyHex: nil, + nostrPublicKey: "npub123" + ) + + #expect(first == second) + #expect(Set([first, second]).count == 1) + } + + @Test("ConversationStore normalizes timeline ordering and duplicates") + @MainActor + func conversationStoreNormalizesMessages() { + let store = ConversationStore() + let older = BitchatMessage( + id: "m1", + sender: "alice", + content: "first", + timestamp: Date(timeIntervalSince1970: 1), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: PeerID(str: "peer-a") + ) + let newer = BitchatMessage( + id: "m2", + sender: "alice", + content: "second", + timestamp: Date(timeIntervalSince1970: 2), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: PeerID(str: "peer-a") + ) + let replacement = BitchatMessage( + id: "m2", + sender: "alice", + content: "second-updated", + timestamp: Date(timeIntervalSince1970: 2), + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: PeerID(str: "peer-a") + ) + + store.replaceMessages([newer, older, replacement], for: ConversationID.mesh) + + let messages = store.messages(for: ConversationID.mesh) + #expect(messages.map(\.id) == ["m1", "m2"]) + #expect(messages.last?.content == "second-updated") + } + + @Test("ConversationStore tracks unread direct conversations with canonical IDs") + @MainActor + func conversationStoreTracksUnreadDirectConversations() { + let store = ConversationStore() + let resolver = IdentityResolver() + let peerID = PeerID(str: "peer-1") + let message = BitchatMessage( + id: "dm-1", + sender: "alice", + content: "hello", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "bob", + senderPeerID: peerID + ) + + store.synchronizePrivateChats( + [peerID: [message]], + unreadPeerIDs: Set([peerID]), + identityResolver: resolver + ) + + let conversationID = ConversationID.direct( + resolver.canonicalHandle(for: peerID, displayName: "alice") + ) + + #expect(store.messages(for: conversationID).map(\.id) == ["dm-1"]) + #expect(store.unreadConversations.contains(conversationID)) + + store.markRead(conversationID) + #expect(!store.unreadConversations.contains(conversationID)) + } + + @Test("ConversationStore tracks the selected app conversation context") + @MainActor + func conversationStoreTracksSelectedConversationContext() { + let store = ConversationStore() + let resolver = IdentityResolver() + let noiseKey = Data((0..<32).map(UInt8.init)) + let shortPeerID = PeerID(str: "0011223344556677") + let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) + let peer = BitchatPeer( + peerID: shortPeerID, + noisePublicKey: noiseKey, + nickname: "alice", + isConnected: true, + isReachable: true + ) + + resolver.register(peers: [peer]) + store.synchronizeSelection( + activeChannel: geohashChannel, + selectedPeerID: shortPeerID, + identityResolver: resolver + ) + + let expectedConversationID = ConversationID.direct( + resolver.canonicalHandle(for: shortPeerID, displayName: "alice") + ) + + #expect(store.activeChannel == geohashChannel) + #expect(store.selectedPrivatePeerID == shortPeerID) + #expect(store.selectedConversationID == expectedConversationID) + + store.synchronizeSelection( + activeChannel: ChannelID.mesh, + selectedPeerID: nil, + identityResolver: resolver + ) + + #expect(store.activeChannel == ChannelID.mesh) + #expect(store.selectedPrivatePeerID == nil) + #expect(store.selectedConversationID == ConversationID.mesh) + } + + @Test("ConversationStore exposes direct conversations by the latest routing peer ID") + @MainActor + func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() { + let store = ConversationStore() + let resolver = IdentityResolver() + let noiseKey = Data((0..<32).map(UInt8.init)) + let shortPeerID = PeerID(str: "0011223344556677") + let fullPeerID = PeerID(hexData: noiseKey) + let firstMessage = BitchatMessage( + id: "dm-1", + sender: "alice", + content: "short id", + timestamp: Date(timeIntervalSince1970: 1), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "builder", + senderPeerID: shortPeerID + ) + let secondMessage = BitchatMessage( + id: "dm-2", + sender: "alice", + content: "full id", + timestamp: Date(timeIntervalSince1970: 2), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "builder", + senderPeerID: fullPeerID + ) + + resolver.register( + peer: BitchatPeer( + peerID: shortPeerID, + noisePublicKey: noiseKey, + nickname: "alice", + isConnected: true, + isReachable: true + ) + ) + store.synchronizePrivateChats( + [shortPeerID: [firstMessage]], + unreadPeerIDs: Set([shortPeerID]), + identityResolver: resolver + ) + + resolver.register( + peer: BitchatPeer( + peerID: fullPeerID, + noisePublicKey: noiseKey, + nickname: "alice", + isConnected: true, + isReachable: true + ) + ) + store.synchronizePrivateChats( + [fullPeerID: [secondMessage]], + unreadPeerIDs: Set([fullPeerID]), + identityResolver: resolver + ) + + #expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID])) + #expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"]) + #expect(store.unreadDirectPeerIDs() == Set([fullPeerID])) + } + + @Test("PrivateInboxModel mirrors direct message state from ConversationStore") + @MainActor + func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async { + let store = ConversationStore() + let resolver = IdentityResolver() + let inboxModel = PrivateInboxModel(conversationStore: store) + let messagePeerID = PeerID(str: "peer-1") + let unreadOnlyPeerID = PeerID(str: "peer-2") + let selectedOnlyPeerID = PeerID(str: "peer-3") + let message = BitchatMessage( + id: "dm-1", + sender: "alice", + content: "hello", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "builder", + senderPeerID: messagePeerID + ) + + store.synchronizePrivateChats( + [messagePeerID: [message]], + unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]), + identityResolver: resolver + ) + store.synchronizeSelection( + activeChannel: ChannelID.mesh, + selectedPeerID: selectedOnlyPeerID, + identityResolver: resolver + ) + + await waitUntil { + inboxModel.selectedPeerID == selectedOnlyPeerID && + inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) && + Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID]) + } + + #expect(inboxModel.selectedPeerID == selectedOnlyPeerID) + #expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID])) + #expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"]) + #expect(inboxModel.messages(for: unreadOnlyPeerID).isEmpty) + #expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty) + } + + @Test("AppChromeModel mirrors nickname and unread state through focused models") + @MainActor + func appChromeModelMirrorsNicknameAndUnreadState() async { + let viewModel = makeArchitectureViewModel() + let conversationStore = ConversationStore() + let resolver = IdentityResolver() + let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + + chromeModel.setNickname("builder") + await waitUntil { + viewModel.nickname == "builder" && chromeModel.nickname == "builder" + } + + #expect(viewModel.nickname == "builder") + #expect(chromeModel.nickname == "builder") + #expect(!chromeModel.hasUnreadPrivateMessages) + + let peerID = PeerID(str: "peer-1") + conversationStore.synchronizePrivateChats( + [:], + unreadPeerIDs: Set([peerID]), + identityResolver: resolver + ) + await waitUntil { + chromeModel.hasUnreadPrivateMessages + } + + #expect(chromeModel.hasUnreadPrivateMessages) + } + + @Test("AppChromeModel owns fingerprint and screenshot presentation state") + @MainActor + func appChromeModelOwnsPresentationState() { + let viewModel = makeArchitectureViewModel() + let conversationStore = ConversationStore() + let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel) + let peerID = PeerID(str: "peer-2") + + chromeModel.showFingerprint(for: peerID) + chromeModel.presentAppInfo() + chromeModel.isLocationChannelsSheetPresented = true + chromeModel.triggerScreenshotPrivacyWarning() + + #expect(chromeModel.showingFingerprintFor == peerID) + #expect(chromeModel.isAppInfoPresented) + #expect(chromeModel.shouldSuppressScreenshotNotification) + #expect(chromeModel.showScreenshotPrivacyWarning) + + chromeModel.clearFingerprint() + #expect(chromeModel.showingFingerprintFor == nil) + } + + @Test("PrivateConversationModel resolves canonical header state for the selected DM") + @MainActor + func privateConversationModelResolvesSelectedHeaderState() async { + let viewModel = makeArchitectureViewModel() + guard let transport = viewModel.meshService as? MockTransport else { + Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") + return + } + let conversationStore = viewModel.conversationStore + let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) + let conversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: conversationStore, + locationChannelsModel: locationChannelsModel + ) + + let noiseKey = Data((0..<32).map(UInt8.init)) + let shortPeerID = PeerID(str: "0011223344556677") + let fullPeerID = PeerID(hexData: noiseKey) + transport.peerNicknames[shortPeerID] = "alice" + transport.reachablePeers.insert(shortPeerID) + viewModel.allPeers = [ + BitchatPeer( + peerID: shortPeerID, + noisePublicKey: noiseKey, + nickname: "alice", + isConnected: false, + isReachable: true + ) + ] + + conversationModel.startConversation(with: fullPeerID) + await waitUntil { + conversationModel.selectedPeerID == fullPeerID + } + + #expect(conversationModel.selectedPeerID == fullPeerID) + #expect(conversationModel.selectedHeaderState?.headerPeerID == shortPeerID) + #expect(conversationModel.selectedHeaderState?.displayName == "alice") + #expect(conversationModel.selectedHeaderState?.availability == .meshReachable) + #expect(conversationModel.selectedHeaderState?.encryptionStatus == .noHandshake) + + conversationModel.endConversation() + await waitUntil { + conversationModel.selectedPeerID == nil + } + #expect(conversationModel.selectedPeerID == nil) + #expect(conversationModel.selectedHeaderState == nil) + } + + @Test("ConversationUIModel mirrors composer state and forwards sends") + @MainActor + func conversationUIModelMirrorsComposerStateAndForwardsSends() async { + let locationManager = makeArchitectureLocationManager() + let viewModel = makeArchitectureViewModel(locationManager: locationManager) + guard let transport = viewModel.meshService as? MockTransport else { + Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") + return + } + + let conversationStore = viewModel.conversationStore + locationManager.select(.mesh) + let locationChannelsModel = LocationChannelsModel(manager: locationManager) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: conversationStore, + locationChannelsModel: locationChannelsModel + ) + let uiModel = ConversationUIModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel, + conversationStore: conversationStore + ) + let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy")) + defer { + locationManager.select(.mesh) + } + viewModel.nickname = "builder" + viewModel.autocompleteSuggestions = ["alice"] + viewModel.showAutocomplete = true + locationChannelsModel.select(geohashChannel) + + await waitUntil { + viewModel.activeChannel == geohashChannel && + uiModel.currentNickname == "builder" && + uiModel.showAutocomplete && + uiModel.autocompleteSuggestions == ["alice"] && + !uiModel.canSendMediaInCurrentContext + } + + #expect(viewModel.activeChannel == geohashChannel) + #expect(uiModel.currentNickname == "builder") + #expect(uiModel.showAutocomplete) + #expect(uiModel.autocompleteSuggestions == ["alice"]) + #expect(!uiModel.canSendMediaInCurrentContext) + + locationChannelsModel.select(ChannelID.mesh) + await waitUntil { + viewModel.activeChannel == ChannelID.mesh && + uiModel.canSendMediaInCurrentContext + } + + #expect(viewModel.activeChannel == ChannelID.mesh) + #expect(uiModel.canSendMediaInCurrentContext) + + uiModel.sendMessage("hello mesh") + + await waitUntil { + transport.sentMessages.last?.content == "hello mesh" + } + + #expect(transport.sentMessages.last?.content == "hello mesh") + } + + @Test("VerificationModel bridges selected conversation and fingerprint actions") + @MainActor + func verificationModelBridgesSelectedConversationAndFingerprintActions() async { + let viewModel = makeArchitectureViewModel() + guard let transport = viewModel.meshService as? MockTransport else { + Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") + return + } + + let peerID = PeerID(str: "0011223344556677") + let fingerprint = "verified-fingerprint" + let conversationStore = viewModel.conversationStore + let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager()) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: conversationStore, + locationChannelsModel: locationChannelsModel + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel + ) + + transport.peerFingerprints[peerID] = fingerprint + transport.peerNicknames[peerID] = "alice" + viewModel.allPeers = [ + BitchatPeer( + peerID: peerID, + noisePublicKey: Data((0..<32).map(UInt8.init)), + nickname: "alice", + isConnected: true, + isReachable: true + ) + ] + + privateConversationModel.startConversation(with: peerID) + await waitUntil { + verificationModel.selectedPeerID == peerID + } + + let presentation = verificationModel.fingerprintPresentation(for: peerID) + #expect(verificationModel.selectedPeerID == peerID) + #expect(presentation.peerNickname == "alice") + #expect(presentation.theirFingerprint == fingerprint) + #expect(!presentation.myFingerprint.isEmpty) + #expect(!verificationModel.isVerified(peerID: peerID)) + + verificationModel.verifyFingerprint(for: peerID) + await waitUntil { + verificationModel.isVerified(peerID: peerID) + } + #expect(verificationModel.isVerified(peerID: peerID)) + + verificationModel.unverifyFingerprint(for: peerID) + await waitUntil { + !verificationModel.isVerified(peerID: peerID) + } + #expect(!verificationModel.isVerified(peerID: peerID)) + } + + @Test("PeerListModel publishes mesh and geohash directory state") + @MainActor + func peerListModelPublishesDirectoryState() async { + let viewModel = makeArchitectureViewModel() + guard let transport = viewModel.meshService as? MockTransport else { + Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests") + return + } + + let myPeerID = PeerID(str: "me-peer") + let otherPeerID = PeerID(str: "0011223344556677") + let geohash = "9q8yy" + let remoteGeoID = String(repeating: "b", count: 64) + let locationManager = makeArchitectureLocationManager() + let locationChannelsModel = LocationChannelsModel(manager: locationManager) + let otherNoiseKey = Data((0..<32).map(UInt8.init)) + let verifiedFingerprint = otherNoiseKey.sha256Fingerprint() + + transport.myPeerID = myPeerID + transport.peerFingerprints[otherPeerID] = verifiedFingerprint + transport.peerNicknames[otherPeerID] = "alice" + transport.reachablePeers.insert(otherPeerID) + viewModel.nickname = "builder" + viewModel.verifiedFingerprints.insert(verifiedFingerprint) + viewModel.unreadPrivateMessages = Set([otherPeerID]) + transport.updatePeerSnapshots([ + makeArchitectureSnapshot( + peerID: myPeerID, + nickname: "builder", + connected: true, + noisePublicKey: Data(repeating: 0, count: 32) + ), + makeArchitectureSnapshot( + peerID: otherPeerID, + nickname: "alice", + connected: false, + noisePublicKey: otherNoiseKey + ) + ]) + + locationManager.select(.location(GeohashChannel(level: .city, geohash: geohash))) + await waitUntil { + if case .location(let channel) = locationManager.selectedChannel { + return channel.geohash == geohash && !viewModel.allPeers.isEmpty + } + return false + } + + viewModel.participantTracker.setActiveGeohash(geohash) + viewModel.teleportedGeo = Set([remoteGeoID]) + viewModel.participantTracker.recordParticipant(pubkeyHex: remoteGeoID, geohash: geohash) + if let myGeoID = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased() { + viewModel.participantTracker.recordParticipant(pubkeyHex: myGeoID, geohash: geohash) + } + + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversationStore: viewModel.conversationStore, + locationChannelsModel: locationChannelsModel + ) + + await waitUntil { + peerListModel.reachableMeshPeerCount == 1 && + peerListModel.connectedMeshPeerCount == 0 && + peerListModel.meshRows.contains(where: { $0.peerID == otherPeerID && $0.hasUnread }) && + peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported }) + } + + let meshRow = peerListModel.meshRows.first(where: { $0.peerID == otherPeerID }) + #expect(peerListModel.reachableMeshPeerCount == 1) + #expect(peerListModel.connectedMeshPeerCount == 0) + #expect(meshRow?.displayName == "alice") + #expect(meshRow?.showsVerifiedBadgeWhenOffline == true) + #expect(meshRow?.hasUnread == true) + #expect(peerListModel.visibleGeohashPeerCount >= 1) + #expect(peerListModel.participantCount(for: geohash) >= 1) + #expect(peerListModel.geohashPeople.contains(where: { $0.id == remoteGeoID && $0.isTeleported })) + + viewModel.participantTracker.clear() + viewModel.teleportedGeo = [] + locationManager.markTeleported(for: geohash, false) + locationManager.select(ChannelID.mesh) + await waitUntil { + if case ChannelID.mesh = locationManager.selectedChannel { + return true + } + return false + } + } +} diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 057a97c3..fc87ae6a 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -167,6 +167,31 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060709") + + let message = BitchatMessage( + id: "keep-receipt", + sender: viewModel.nickname, + content: "Keep me", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + viewModel.sentReadReceipts = ["keep-receipt", "drop-receipt"] + viewModel.isStartupPhase = false + + viewModel.cleanupOldReadReceipts() + + #expect(viewModel.sentReadReceipts == ["keep-receipt"]) + } + // MARK: - Public Timeline Status Tests @Test @MainActor diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 1be3b578..d59c911a 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -67,6 +67,135 @@ struct ChatViewModelInitializationTests { // Nickname should be set during init #expect(!transport.myNickname.isEmpty) } + + @Test @MainActor + func initialization_bindsPeerSnapshotsIntoAllPeers() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "00000000000000a1") + let noiseKey = Data(repeating: 0xAB, count: 32) + + transport.updatePeerSnapshots([ + TransportPeerSnapshot( + peerID: peerID, + nickname: "Alice", + isConnected: true, + noisePublicKey: noiseKey, + lastSeen: Date() + ) + ]) + + let updated = await TestHelpers.waitUntil({ + viewModel.allPeers.contains { $0.peerID == peerID && $0.nickname == "Alice" } + }, timeout: TestConstants.defaultTimeout) + + #expect(updated) + } +} + +// MARK: - Identity Tests + +struct ChatViewModelIdentityTests { + + @Test @MainActor + func updatePrivateChatPeerIfNeeded_migratesSelectionHistoryByFingerprint() async { + let (viewModel, transport) = makeTestableViewModel() + let oldPeerID = PeerID(str: "00000000000000d1") + let newPeerID = PeerID(str: "00000000000000d2") + let sharedFingerprint = "fingerprint-shared" + let sharedNoiseKey = Data(repeating: 0xAB, count: 32) + + transport.peerFingerprints[oldPeerID] = sharedFingerprint + transport.peerFingerprints[newPeerID] = sharedFingerprint + transport.peerNicknames[oldPeerID] = "Alice" + transport.peerNicknames[newPeerID] = "Alice" + transport.updatePeerSnapshots([ + TransportPeerSnapshot( + peerID: oldPeerID, + nickname: "Alice", + isConnected: true, + noisePublicKey: sharedNoiseKey, + lastSeen: Date() + ) + ]) + + let oldPeerBound = await TestHelpers.waitUntil({ + viewModel.connectedPeers.contains(oldPeerID) + }, timeout: TestConstants.defaultTimeout) + #expect(oldPeerBound) + + let existingMessage = BitchatMessage( + id: "pm-migrate-1", + sender: "Alice", + content: "Still here", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: oldPeerID, + mentions: nil + ) + viewModel.privateChats[oldPeerID] = [existingMessage] + viewModel.startPrivateChat(with: oldPeerID) + + #expect(viewModel.selectedPrivateChatPeer == oldPeerID) + #expect(viewModel.hasTrackedPrivateChatSelection) + #expect(viewModel.selectedPrivateChatFingerprint == viewModel.getFingerprint(for: oldPeerID)) + + transport.updatePeerSnapshots([ + TransportPeerSnapshot( + peerID: newPeerID, + nickname: "Alice", + isConnected: true, + noisePublicKey: sharedNoiseKey, + lastSeen: Date() + ) + ]) + + let newPeerBound = await TestHelpers.waitUntil({ + viewModel.connectedPeers.contains(newPeerID) && !viewModel.connectedPeers.contains(oldPeerID) + }, timeout: TestConstants.defaultTimeout) + #expect(newPeerBound) + + viewModel.updatePrivateChatPeerIfNeeded() + + #expect(viewModel.selectedPrivateChatPeer == newPeerID) + #expect(viewModel.privateChats[oldPeerID] == nil) + #expect(viewModel.privateChats[newPeerID]?.contains(where: { $0.id == "pm-migrate-1" }) == true) + } + + @Test @MainActor + func resolveNickname_prefersLocalPetnameOverClaimedNickname() async { + let keychain = MockKeychain() + let keychainHelper = MockKeychainHelper() + let idBridge = NostrIdentityBridge(keychain: keychainHelper) + let identityManager = MockIdentityManager(keychain) + let transport = MockTransport() + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + transport: transport + ) + let peerID = PeerID(str: "00000000000000d3") + let fingerprint = "fingerprint-petname" + + transport.peerFingerprints[peerID] = fingerprint + identityManager.updateSocialIdentity( + SocialIdentity( + fingerprint: fingerprint, + localPetname: "Buddy", + claimedNickname: "Alice", + trustLevel: .trusted, + isFavorite: false, + isBlocked: false, + notes: nil + ) + ) + + #expect(viewModel.getFingerprint(for: peerID) == fingerprint) + #expect(viewModel.resolveNickname(for: peerID) == "Buddy") + } } // MARK: - Message Sending Tests @@ -135,6 +264,116 @@ struct ChatViewModelCommandTests { } } +// MARK: - Composer Tests + +struct ChatViewModelComposerTests { + + @Test @MainActor + func updateAutocomplete_suggestsKnownPeersAndExcludesSelf() async { + let (viewModel, transport) = makeTestableViewModel() + let alice = PeerID(str: "00000000000000a1") + let selfAlias = PeerID(str: "00000000000000a2") + + transport.simulateConnect(alice, nickname: "Alice") + transport.simulateConnect(selfAlias, nickname: viewModel.nickname) + + viewModel.updateAutocomplete(for: "hello @Al", cursorPosition: 9) + + #expect(viewModel.showAutocomplete) + #expect(viewModel.autocompleteSuggestions == ["@Alice"]) + } + + @Test @MainActor + func completeNickname_replacesTokenAndClearsAutocompleteState() async { + let (viewModel, _) = makeTestableViewModel() + var text = "hello @Al" + + viewModel.autocompleteSuggestions = ["@Alice"] + viewModel.autocompleteRange = NSRange(location: 6, length: 3) + viewModel.showAutocomplete = true + viewModel.selectedAutocompleteIndex = 0 + + _ = viewModel.completeNickname("@Alice", in: &text) + + #expect(text == "hello @Alice") + #expect(viewModel.autocompleteSuggestions.isEmpty) + #expect(viewModel.autocompleteRange == nil) + #expect(!viewModel.showAutocomplete) + #expect(viewModel.selectedAutocompleteIndex == 0) + } + + @Test @MainActor + func parseMentions_filtersToKnownPeerAndSelfTokens() async { + let (viewModel, transport) = makeTestableViewModel() + let alice = PeerID(str: "00000000000000b1") + + transport.simulateConnect(alice, nickname: "Alice") + + let mentions = Set( + viewModel.parseMentions( + from: "hi @Alice @nobody @\(viewModel.nickname)" + ) + ) + + #expect(mentions.contains("Alice")) + #expect(mentions.contains(viewModel.nickname)) + #expect(!mentions.contains("nobody")) + } +} + +// MARK: - Lifecycle Tests + +struct ChatViewModelServiceLifecycleTests { + + @Test @MainActor + func handleDidBecomeActive_marksVisiblePrivateMessagesAsRead() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000001") + transport.simulateConnect(peerID, nickname: "Alice") + + let message = BitchatMessage( + id: "read-1", + sender: "Alice", + content: "Hello from Alice", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID, + mentions: nil + ) + + viewModel.privateChats[peerID] = [message] + viewModel.unreadPrivateMessages.insert(peerID) + viewModel.selectedPrivateChatPeer = peerID + + viewModel.handleDidBecomeActive() + + let sentReadReceipt = await TestHelpers.waitUntil({ + transport.sentReadReceipts.contains { + $0.peerID == peerID && $0.receipt.originalMessageID == "read-1" + } + }, timeout: TestConstants.defaultTimeout) + + #expect(sentReadReceipt) + #expect(!viewModel.unreadPrivateMessages.contains(peerID)) + } + + @Test @MainActor + func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000002") + transport.simulateConnect(peerID, nickname: "Alice") + + viewModel.selectedPrivateChatPeer = peerID + viewModel.handleScreenshotCaptured() + + #expect(transport.sentPrivateMessages.isEmpty) + #expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot") + } +} + // MARK: - Timeline Cap Tests struct ChatViewModelTimelineCapTests { @@ -159,7 +398,7 @@ struct ChatViewModelReceivingTests { @Test @MainActor func didReceiveMessage_callsDelegate() async { - let (_, transport) = makeTestableViewModel() + let (viewModel, transport) = makeTestableViewModel() let message = BitchatMessage( id: "msg-001", @@ -181,7 +420,7 @@ struct ChatViewModelReceivingTests { // Message may or may not appear due to rate limiting/pipeline batching // The important thing is no crash and delegate was called - #expect(transport.delegate != nil) + #expect(transport.delegate === viewModel) } @Test @MainActor @@ -204,6 +443,176 @@ struct ChatViewModelReceivingTests { } } +// MARK: - Noise Payload Tests + +struct ChatViewModelNoisePayloadTests { + + @Test @MainActor + func didReceiveNoisePayload_privateMessageStoresMessageAndSendsDeliveryAck() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000003") + transport.simulateConnect(peerID, nickname: "Alice") + + let payload = PrivateMessagePacket(messageID: "pm-noise-1", content: "Secret hello").encode() + #expect(payload != nil) + guard let payload else { return } + + viewModel.didReceiveNoisePayload( + from: peerID, + type: .privateMessage, + payload: payload, + timestamp: Date() + ) + + let stored = await TestHelpers.waitUntil({ + viewModel.privateChats[peerID]?.contains(where: { $0.id == "pm-noise-1" && $0.content == "Secret hello" }) == true + }, timeout: TestConstants.defaultTimeout) + + let acked = await TestHelpers.waitUntil({ + transport.sentDeliveryAcks.contains { $0.messageID == "pm-noise-1" && $0.peerID == peerID } + }, timeout: TestConstants.defaultTimeout) + + #expect(stored) + #expect(acked) + } + + @Test @MainActor + func didReceiveNoisePayload_deliveredUpdatesExistingPrivateMessageStatus() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0000000000000004") + transport.simulateConnect(peerID, nickname: "Bob") + + let message = BitchatMessage( + id: "pm-delivered-1", + sender: viewModel.nickname, + content: "Waiting on ack", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: "Bob", + senderPeerID: viewModel.meshService.myPeerID, + mentions: nil, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + + viewModel.didReceiveNoisePayload( + from: peerID, + type: .delivered, + payload: Data("pm-delivered-1".utf8), + timestamp: Date() + ) + + let delivered = await TestHelpers.waitUntil({ + guard let status = viewModel.privateChats[peerID]?.first?.deliveryStatus else { return false } + if case .delivered(let name, _) = status { + return name == "Bob" + } + return false + }, timeout: TestConstants.defaultTimeout) + + #expect(delivered) + } +} + +// MARK: - Formatting Tests + +struct ChatViewModelFormattingTests { + + @Test @MainActor + func formatMessageAsText_formatsSenderContentAndTimestamp() async { + let (viewModel, _) = makeTestableViewModel() + let message = BitchatMessage( + id: "fmt-1", + sender: "Alice#a1b2", + content: "hello #mesh", + timestamp: Date(timeIntervalSince1970: 1_700_010_000), + isRelay: false, + senderPeerID: PeerID(str: "00000000000000b1") + ) + + let formatted = viewModel.formatMessageAsText(message, colorScheme: .light) + + #expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]") + } + + @Test @MainActor + func formatMessageHeader_formatsSenderHeader() async { + let (viewModel, _) = makeTestableViewModel() + let message = BitchatMessage( + id: "fmt-2", + sender: "Alice#a1b2", + content: "hello", + timestamp: Date(), + isRelay: false, + senderPeerID: PeerID(str: "00000000000000b2") + ) + + let header = viewModel.formatMessageHeader(message, colorScheme: .dark) + + #expect(String(header.characters) == "<@Alice#a1b2> ") + } +} + +// MARK: - Verification Tests + +struct ChatViewModelVerificationTests { + + @Test @MainActor + func beginQRVerification_unknownNoiseKeyReturnsFalse() async { + let (viewModel, _) = makeTestableViewModel() + let qr = VerificationService.VerificationQR( + v: 1, + noiseKeyHex: String(repeating: "a", count: 64), + signKeyHex: String(repeating: "b", count: 64), + npub: nil, + nickname: "Alice", + ts: 1_700_000_000, + nonceB64: "nonce", + sigHex: "sig" + ) + + #expect(!viewModel.beginQRVerification(with: qr)) + } + + @Test @MainActor + func beginQRVerification_knownPeerTriggersHandshakeWhenSessionNotEstablished() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "00000000000000c1") + let noiseKey = Data(repeating: 0xAB, count: 32) + + transport.updatePeerSnapshots([ + TransportPeerSnapshot( + peerID: peerID, + nickname: "Alice", + isConnected: true, + noisePublicKey: noiseKey, + lastSeen: Date() + ) + ]) + + let bound = await TestHelpers.waitUntil({ + viewModel.unifiedPeerService.peers.contains { $0.peerID == peerID } + }, timeout: TestConstants.defaultTimeout) + #expect(bound) + + let qr = VerificationService.VerificationQR( + v: 1, + noiseKeyHex: noiseKey.hexEncodedString(), + signKeyHex: String(repeating: "c", count: 64), + npub: nil, + nickname: "Alice", + ts: 1_700_000_000, + nonceB64: "nonce", + sigHex: "sig" + ) + + #expect(viewModel.beginQRVerification(with: qr)) + #expect(transport.triggeredHandshakes == [peerID]) + } +} + // MARK: - Rate Limiting Tests struct ChatViewModelRateLimitingTests { @@ -238,6 +647,35 @@ struct ChatViewModelRateLimitingTests { } } +// MARK: - Public Conversation Tests + +struct ChatViewModelPublicConversationTests { + + @Test @MainActor + func addPublicSystemMessage_persistsAcrossTimelineRefresh() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.addPublicSystemMessage("system refresh test") + viewModel.messages.removeAll() + viewModel.refreshVisibleMessages(from: .mesh) + + #expect(viewModel.messages.last?.content == "system refresh test") + } + + @Test @MainActor + func clearCurrentPublicTimeline_clearsVisibleAndBackedMessages() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.addPublicSystemMessage("system clear test") + #expect(!viewModel.messages.isEmpty) + + viewModel.clearCurrentPublicTimeline() + viewModel.refreshVisibleMessages(from: .mesh) + + #expect(viewModel.messages.isEmpty) + } +} + // MARK: - Peer Connection Tests struct ChatViewModelPeerTests { @@ -275,6 +713,46 @@ struct ChatViewModelPeerTests { #expect(transport.isPeerConnected(peerID)) } + + @Test @MainActor + func didUpdatePeerList_removesStaleUnreadPeerWithoutMessages() async { + let (viewModel, _) = makeTestableViewModel() + let stalePeer = PeerID(str: "00000000000000a2") + viewModel.unreadPrivateMessages = [stalePeer] + + viewModel.didUpdatePeerList([]) + + let cleaned = await TestHelpers.waitUntil({ + !viewModel.unreadPrivateMessages.contains(stalePeer) + }, timeout: TestConstants.defaultTimeout) + + #expect(cleaned) + } + + @Test @MainActor + func didUpdatePeerList_preservesStableUnreadPeerWhenMessagesExist() async { + let (viewModel, _) = makeTestableViewModel() + let stablePeer = PeerID(str: String(repeating: "a", count: 64)) + let message = BitchatMessage( + id: "stable-noise-1", + sender: "Alice", + content: "Offline hello", + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: stablePeer, + mentions: nil + ) + viewModel.privateChats[stablePeer] = [message] + viewModel.unreadPrivateMessages = [stablePeer] + + viewModel.didUpdatePeerList([]) + try? await Task.sleep(nanoseconds: 100_000_000) + + #expect(viewModel.unreadPrivateMessages.contains(stablePeer)) + } } // MARK: - Deduplication Integration Tests @@ -328,7 +806,7 @@ struct ChatViewModelPrivateChatTests { // The message routing depends on connection state and other factors // At minimum, it should not crash - #expect(true) // If we get here without crash, the test passes + #expect(Bool(true)) // If we get here without crash, the test passes } } diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 12e57d27..214d675f 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -53,7 +53,7 @@ struct CommandProcessorTests { context.nicknameToPeerID["alice"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/msg @alice hello there") } @@ -72,14 +72,15 @@ struct CommandProcessorTests { @MainActor @Test func whoInMeshListsSortedPeerNicknames() async { let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() let transport = MockTransport() transport.peerNicknames = [ PeerID(str: "b"): "bob", PeerID(str: "a"): "alice" ] - let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager) + let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/who") } @@ -105,7 +106,7 @@ struct CommandProcessorTests { let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let result = await withSelectedChannel(channel) { + let result = await withSelectedChannel(channel, context: context) { processor.process("/who") } @@ -130,7 +131,7 @@ struct CommandProcessorTests { ] let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/clear") } @@ -150,7 +151,7 @@ struct CommandProcessorTests { let context = MockCommandContextProvider() let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/clear") } @@ -174,7 +175,7 @@ struct CommandProcessorTests { transport.peerNicknames[peerID] = "Bob" let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/hug @bob") } @@ -198,7 +199,7 @@ struct CommandProcessorTests { context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/slap @bob") } @@ -227,7 +228,7 @@ struct CommandProcessorTests { identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true) let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let result = await withSelectedChannel(.mesh) { + let result = await withSelectedChannel(.mesh, context: context) { processor.process("/block") } @@ -249,7 +250,7 @@ struct CommandProcessorTests { context.nicknameToPeerID["bob"] = peerID let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager) - let blockResult = await withSelectedChannel(.mesh) { + let blockResult = await withSelectedChannel(.mesh, context: context) { processor.process("/block @bob") } switch blockResult { @@ -260,7 +261,7 @@ struct CommandProcessorTests { } #expect(identityManager.isBlocked(fingerprint: "fp-bob")) - let unblockResult = await withSelectedChannel(.mesh) { + let unblockResult = await withSelectedChannel(.mesh, context: context) { processor.process("/unblock bob") } switch unblockResult { @@ -279,7 +280,7 @@ struct CommandProcessorTests { context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64) let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager) - let blockResult = await withSelectedChannel(.mesh) { + let blockResult = await withSelectedChannel(.mesh, context: context) { processor.process("/block carol") } switch blockResult { @@ -290,7 +291,7 @@ struct CommandProcessorTests { } #expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64))) - let unblockResult = await withSelectedChannel(.mesh) { + let unblockResult = await withSelectedChannel(.mesh, context: context) { processor.process("/unblock @carol") } switch unblockResult { @@ -305,14 +306,15 @@ struct CommandProcessorTests { @MainActor @Test func favoriteCommandIsRejectedOutsideMesh() async { let identityManager = MockIdentityManager(MockKeychain()) + let context = MockCommandContextProvider() let processor = CommandProcessor( - contextProvider: MockCommandContextProvider(), + contextProvider: context, meshService: MockTransport(), identityManager: identityManager ) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy")) - let result = await withSelectedChannel(channel) { + let result = await withSelectedChannel(channel, context: context) { processor.process("/fav alice") } @@ -325,21 +327,31 @@ struct CommandProcessorTests { } @MainActor - private func withSelectedChannel(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T { + private func withSelectedChannel( + _ channel: ChannelID, + context: MockCommandContextProvider? = nil, + perform work: @escaping () throws -> T + ) async rethrows -> T { let originalChannel = LocationChannelManager.shared.selectedChannel - await setSelectedChannel(channel) + let originalContextChannel = context?.activeChannel + await setSelectedChannel(channel, context: context) do { let result = try work() - await setSelectedChannel(originalChannel) + await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel) return result } catch { - await setSelectedChannel(originalChannel) + await setSelectedChannel(originalChannel, context: context, explicitChannel: originalContextChannel) throw error } } @MainActor - private func setSelectedChannel(_ channel: ChannelID) async { + private func setSelectedChannel( + _ channel: ChannelID, + context: MockCommandContextProvider? = nil, + explicitChannel: ChannelID? = nil + ) async { + context?.activeChannel = explicitChannel ?? channel LocationChannelManager.shared.select(channel) for _ in 0..<40 { if LocationChannelManager.shared.selectedChannel == channel { @@ -363,6 +375,7 @@ struct CommandProcessorTests { @MainActor private final class MockCommandContextProvider: CommandContextProvider { var nickname: String + var activeChannel: ChannelID = .mesh var selectedPrivateChatPeer: PeerID? var blockedUsers: Set = [] var privateChats: [PeerID: [BitchatMessage]] = [:] diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index c645b1f9..59fdc364 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -12,26 +12,12 @@ import CoreBluetooth import BitFoundation @testable import bitchat +@Suite("Fragmentation Tests", .serialized) struct FragmentationTests { - - private let mockKeychain: MockKeychain - private let mockIdentityManager: MockIdentityManager - private let idBridge: NostrIdentityBridge - - init() { - mockKeychain = MockKeychain() - mockIdentityManager = MockIdentityManager(mockKeychain) - idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) - } - + @Test("Reassembly from fragments delivers a public message") func reassemblyFromFragmentsDeliversPublicMessage() async throws { - let ble = BLEService( - keychain: mockKeychain, - idBridge: idBridge, - identityManager: mockIdentityManager, - initializeBluetoothManagers: false - ) + let ble = makeBLEService() let capture = CaptureDelegate() ble.delegate = capture @@ -54,7 +40,7 @@ struct FragmentationTests { } // Wait for delegate callback with proper timeout - try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) + try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 3_000) @@ -62,12 +48,7 @@ struct FragmentationTests { @Test("Duplicate fragment does not break reassembly") func duplicateFragmentDoesNotBreakReassembly() async throws { - let ble = BLEService( - keychain: mockKeychain, - idBridge: idBridge, - identityManager: mockIdentityManager, - initializeBluetoothManagers: false - ) + let ble = makeBLEService() let capture = CaptureDelegate() ble.delegate = capture @@ -89,7 +70,7 @@ struct FragmentationTests { } // Wait for delegate callback with proper timeout - try await capture.waitForPublicMessages(count: 1, timeout: .seconds(2)) + try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5)) #expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.first?.content.count == 2048) @@ -97,12 +78,7 @@ struct FragmentationTests { @Test("Max-sized file transfer survives reassembly") func maxSizedFileTransferSurvivesReassembly() async throws { - let ble = BLEService( - keychain: mockKeychain, - idBridge: idBridge, - identityManager: mockIdentityManager, - initializeBluetoothManagers: false - ) + let ble = makeBLEService() let capture = CaptureDelegate() ble.delegate = capture @@ -131,14 +107,13 @@ struct FragmentationTests { #expect(!fragments.isEmpty) for (i, fragment) in fragments.enumerated() { - let delay = 5 * Double(i) * 0.001 - Task { - try await sleep(delay) - ble._test_handlePacket(fragment, fromPeerID: remoteID) + if i > 0 { + try await Task.sleep(for: .milliseconds(5)) } + ble._test_handlePacket(fragment, fromPeerID: remoteID) } - try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2)) + try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5)) let message = try #require(capture.receivedMessages.first, "Expected file transfer message") #expect(message.content.hasPrefix("[file]")) @@ -154,12 +129,7 @@ struct FragmentationTests { @Test("Invalid fragment header is ignored") func invalidFragmentHeaderIsIgnored() async throws { - let ble = BLEService( - keychain: mockKeychain, - idBridge: idBridge, - identityManager: mockIdentityManager, - initializeBluetoothManagers: false - ) + let ble = makeBLEService() let capture = CaptureDelegate() ble.delegate = capture @@ -184,11 +154,10 @@ struct FragmentationTests { } for (i, fragment) in corrupted.enumerated() { - let delay = 5 * Double(i) * 0.001 - Task { - try await sleep(delay) - ble._test_handlePacket(fragment, fromPeerID: remoteShortID) + if i > 0 { + try await Task.sleep(for: .milliseconds(5)) } + ble._test_handlePacket(fragment, fromPeerID: remoteShortID) } // Allow async processing @@ -200,6 +169,19 @@ struct FragmentationTests { } extension FragmentationTests { + private func makeBLEService() -> BLEService { + let mockKeychain = MockKeychain() + let mockIdentityManager = MockIdentityManager(mockKeychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + + return BLEService( + keychain: mockKeychain, + idBridge: idBridge, + identityManager: mockIdentityManager, + initializeBluetoothManagers: false + ) + } + /// Thread-safe delegate that supports awaiting message delivery private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index 5d3ce8df..ee9fb3d2 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -4,6 +4,8 @@ import XCTest @MainActor final class NostrRelayManagerTests: XCTestCase { + private let expectedDefaultRelayCount = 4 + func test_connect_directMode_connectsExistingDefaultRelaysWhenActivationBecomesAllowed() async { let context = makeContext(permission: .authorized, activationAllowed: false) @@ -13,7 +15,7 @@ final class NostrRelayManagerTests: XCTestCase { context.manager.connect() let connected = await waitUntil { - context.sessionFactory.requestedURLs.count == 5 && + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) @@ -27,7 +29,7 @@ final class NostrRelayManagerTests: XCTestCase { context.permissionSubject.send(.authorized) let defaultRelaysConnected = await waitUntil { - context.manager.getRelayStatuses().count == 5 && + context.manager.getRelayStatuses().count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(defaultRelaysConnected) @@ -38,7 +40,7 @@ final class NostrRelayManagerTests: XCTestCase { context.manager.getRelayStatuses().isEmpty } XCTAssertTrue(defaultRelaysRemoved) - XCTAssertEqual(context.sessionFactory.allConnections.count, 5) + XCTAssertEqual(context.sessionFactory.allConnections.count, expectedDefaultRelayCount) XCTAssertTrue(context.sessionFactory.allConnections.allSatisfy { $0.cancelCallCount >= 1 }) } @@ -52,7 +54,26 @@ final class NostrRelayManagerTests: XCTestCase { context.torWaiter.resolve(true) let connectedAfterTorReady = await waitUntil { - context.sessionFactory.requestedURLs.count == 5 && + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connectedAfterTorReady) + } + + func test_connect_coalescesRepeatedCallsWhileWaitingForTor() async { + let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.connect() + context.manager.connect() + context.manager.connect() + + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let connectedAfterTorReady = await waitUntil { + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connectedAfterTorReady) @@ -87,6 +108,24 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertTrue(sentAfterTorReady) } + func test_sendEvent_queuesUntilRelayIsMarkedConnected() async throws { + let relayURL = "wss://connect-before-send.example" + let context = makeContext(permission: .denied) + let event = try makeSignedEvent(content: "wait for connected") + + context.manager.sendEvent(event, to: [relayURL]) + + XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 0) + XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1) + + let sentAfterConnected = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 && + context.manager.debugPendingMessageQueueCount == 0 && + context.manager.relays.first(where: { $0.url == relayURL })?.messagesSent == 1 + } + XCTAssertTrue(sentAfterConnected) + } + func test_sendEvent_queuesWhileBackgroundedAndFlushesWhenForegrounded() async throws { let relayURL = "wss://queue-flush.example" let context = makeContext( @@ -179,6 +218,26 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.sessionFactory.requestedURLs, [relayOne, relayTwo]) } + func test_ensureConnections_coalescesTargetsWhileWaitingForTor() async { + let relayOne = "wss://tor-one.example" + let relayTwo = "wss://tor-two.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.ensureConnections(to: [relayOne]) + context.manager.ensureConnections(to: [relayTwo, relayOne]) + + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let connected = await waitUntil { + Set(context.sessionFactory.requestedURLs) == Set([relayOne, relayTwo]) && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connected) + } + func test_subscribe_coalescesRapidDuplicateRequests() async { let relayURL = "wss://subscribe.example" let context = makeContext(permission: .denied) @@ -197,6 +256,63 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1) } + func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws { + let relayURL = "wss://tor-subscribe-coalesce.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "tor-sub", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + context.manager.subscribe( + filter: makeFilter(), + id: "tor-sub", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 1) + try? await Task.sleep(nanoseconds: 20_000_000) + XCTAssertEqual(eoseCount, 0) + + context.torWaiter.resolve(true) + + let subscribed = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(subscribed) + + try context.sessionFactory.latestConnection(for: relayURL)?.emitEOSE(subscriptionID: "tor-sub") + let eoseCompleted = await waitUntil { eoseCount == 1 } + XCTAssertTrue(eoseCompleted) + } + + func test_subscribe_sameActiveRequestDoesNotRequeue() async { + let relayURL = "wss://active-subscribe.example" + let context = makeContext(permission: .denied) + let filter = makeFilter() + + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + + let firstSent = await waitUntil { + context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 + } + XCTAssertTrue(firstSent) + + context.clock.now = context.clock.now.addingTimeInterval(2.0) + context.manager.subscribe(filter: filter, id: "sub", relayUrls: [relayURL], handler: { _ in }) + try? await Task.sleep(nanoseconds: 20_000_000) + + XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1) + XCTAssertEqual(context.manager.debugPendingSubscriptionCount(for: relayURL), 0) + } + func test_subscribe_waitsForTorReadinessAndPreservesEOSECallback() async throws { let relayURL = "wss://tor-subscribe.example" let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) @@ -239,7 +355,7 @@ final class NostrRelayManagerTests: XCTestCase { context.permissionSubject.send(.authorized) let connected = await waitUntil { - context.sessionFactory.allConnections.count == 5 && + context.sessionFactory.allConnections.count == self.expectedDefaultRelayCount && context.manager.relays.allSatisfy(\.isConnected) } XCTAssertTrue(connected) @@ -810,6 +926,7 @@ private final class MutableBool { private final class MockTorWaiter { private var completions: [(Bool) -> Void] = [] + private(set) var awaitCallCount = 0 var isReady: Bool init(isReady: Bool) { @@ -817,6 +934,7 @@ private final class MockTorWaiter { } func await(completion: @escaping (Bool) -> Void) { + awaitCallCount += 1 completions.append(completion) } diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift index cdf9a8df..9a2aaef3 100644 --- a/bitchatTests/ViewSmokeTests.swift +++ b/bitchatTests/ViewSmokeTests.swift @@ -29,6 +29,151 @@ private func makeSmokeViewModel() -> (viewModel: ChatViewModel, transport: MockT return (viewModel, transport, identityManager) } +@MainActor +private struct SmokeFeatureModels { + let publicChatModel: PublicChatModel + let appChromeModel: AppChromeModel + let locationChannelsModel: LocationChannelsModel + let privateInboxModel: PrivateInboxModel + let privateConversationModel: PrivateConversationModel + let verificationModel: VerificationModel + let conversationUIModel: ConversationUIModel + let peerListModel: PeerListModel +} + +@MainActor +private func makeSmokeLocationManager() -> LocationChannelManager { + let suiteName = "ViewSmokeTests.\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName) ?? .standard + storage.removePersistentDomain(forName: suiteName) + return LocationChannelManager(storage: storage) +} + +@MainActor +private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatureModels { + let locationManager = makeSmokeLocationManager() + let conversationStore = viewModel.conversationStore + let publicChatModel = PublicChatModel(conversationStore: conversationStore) + let locationChannelsModel = LocationChannelsModel(manager: locationManager) + let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore) + let appChromeModel = AppChromeModel( + chatViewModel: viewModel, + privateInboxModel: privateInboxModel + ) + let privateConversationModel = PrivateConversationModel( + chatViewModel: viewModel, + conversationStore: conversationStore, + locationChannelsModel: locationChannelsModel + ) + let verificationModel = VerificationModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel + ) + let conversationUIModel = ConversationUIModel( + chatViewModel: viewModel, + privateConversationModel: privateConversationModel, + conversationStore: conversationStore + ) + let peerListModel = PeerListModel( + chatViewModel: viewModel, + conversationStore: conversationStore, + locationChannelsModel: locationChannelsModel + ) + + return SmokeFeatureModels( + publicChatModel: publicChatModel, + appChromeModel: appChromeModel, + locationChannelsModel: locationChannelsModel, + privateInboxModel: privateInboxModel, + privateConversationModel: privateConversationModel, + verificationModel: verificationModel, + conversationUIModel: conversationUIModel, + peerListModel: peerListModel + ) +} + +@MainActor +private func installSmokeEnvironment( + _ view: V, + featureModels: SmokeFeatureModels +) -> some View { + view + .environmentObject(featureModels.publicChatModel) + .environmentObject(featureModels.appChromeModel) + .environmentObject(featureModels.locationChannelsModel) + .environmentObject(featureModels.privateInboxModel) + .environmentObject(featureModels.privateConversationModel) + .environmentObject(featureModels.verificationModel) + .environmentObject(featureModels.conversationUIModel) + .environmentObject(featureModels.peerListModel) +} + +@MainActor +private struct ContentPeopleSheetHarness: View { + @State private var showSidebar = true + @State private var messageText = "" + @State private var selectedMessageSender: String? + @State private var selectedMessageSenderID: PeerID? + @State private var imagePreviewURL: URL? + @State private var windowCountPublic = 300 + @State private var windowCountPrivate: [PeerID: Int] = [:] + @State private var isAtBottomPrivate = true + @State private var autocompleteDebounceTimer: Timer? + @StateObject private var voiceRecordingVM = VoiceRecordingViewModel() + @FocusState private var isTextFieldFocused: Bool + #if os(iOS) + @State private var showImagePicker = false + @State private var imagePickerSourceType: UIImagePickerController.SourceType = .photoLibrary + #else + @State private var showMacImagePicker = false + #endif + + var body: some View { + #if os(iOS) + ContentPeopleSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: .black, + textColor: .green, + secondaryTextColor: .gray, + headerHeight: 44, + onSendMessage: {}, + showImagePicker: $showImagePicker, + imagePickerSourceType: $imagePickerSourceType + ) + #else + ContentPeopleSheetView( + showSidebar: $showSidebar, + messageText: $messageText, + selectedMessageSender: $selectedMessageSender, + selectedMessageSenderID: $selectedMessageSenderID, + imagePreviewURL: $imagePreviewURL, + windowCountPublic: $windowCountPublic, + windowCountPrivate: $windowCountPrivate, + isAtBottomPrivate: $isAtBottomPrivate, + isTextFieldFocused: $isTextFieldFocused, + voiceRecordingVM: voiceRecordingVM, + autocompleteDebounceTimer: $autocompleteDebounceTimer, + backgroundColor: .black, + textColor: .green, + secondaryTextColor: .gray, + headerHeight: 44, + onSendMessage: {}, + showMacImagePicker: $showMacImagePicker + ) + #endif + } +} + @MainActor @discardableResult private func mount(_ view: V) -> AnyObject { @@ -112,11 +257,13 @@ private func makeTemporaryImageURL() throws -> URL { return url } +@Suite("View Smoke Tests", .serialized) @MainActor struct ViewSmokeTests { @Test func fingerprintView_renders_verifiedAndPendingStates() async { let (viewModel, transport, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let verifiedPeer = PeerID(str: "0102030405060708") let pendingPeer = PeerID(str: "1112131415161718") let verifiedFingerprint = String(repeating: "ab", count: 32) @@ -131,11 +278,11 @@ struct ViewSmokeTests { viewModel.verifiedFingerprints.insert(verifiedFingerprint) - let verifiedView = FingerprintView(viewModel: viewModel, peerID: verifiedPeer) - let pendingView = FingerprintView(viewModel: viewModel, peerID: pendingPeer) + let verifiedView = FingerprintView(peerID: verifiedPeer) + .environmentObject(featureModels.verificationModel) + let pendingView = FingerprintView(peerID: pendingPeer) + .environmentObject(featureModels.verificationModel) - _ = verifiedView.body - _ = pendingView.body _ = mount(verifiedView) _ = mount(pendingView) @@ -145,13 +292,14 @@ struct ViewSmokeTests { @Test func verificationViews_renderCoreBranches() throws { let (viewModel, transport, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let peerID = PeerID(str: "2122232425262728") let fingerprint = String(repeating: "cd", count: 32) var isPresented = true transport.peerFingerprints[peerID] = fingerprint transport.updatePeerSnapshots([makeSnapshot(peerID: peerID, nickname: "Verifier", noiseByte: 0x33)]) - viewModel.selectedPrivateChatPeer = peerID + featureModels.privateConversationModel.startConversation(with: peerID) viewModel.verifiedFingerprints.insert(fingerprint) let image = try makeCGImage() @@ -173,35 +321,36 @@ struct ViewSmokeTests { set: { isPresented = $0 } ) ) - .environmentObject(viewModel) + .environmentObject(featureModels.verificationModel) ) } @Test func meshPeerList_renders_emptyAndPopulatedStates() async { let (viewModel, transport, identityManager) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let connectedPeer = PeerID(str: "3132333435363738") let blockedPeer = PeerID(str: "4142434445464748") let blockedFingerprint = String(repeating: "ef", count: 32) _ = mount( MeshPeerList( - viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) + .environmentObject(featureModels.peerListModel) ) _ = MeshPeerList( - viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } - ).body + ) + .environmentObject(featureModels.peerListModel) transport.peerFingerprints[blockedPeer] = blockedFingerprint identityManager.setBlocked(blockedFingerprint, isBlocked: true) @@ -214,13 +363,13 @@ struct ViewSmokeTests { _ = mount( MeshPeerList( - viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPeer: { _ in }, onToggleFavorite: { _ in }, onShowFingerprint: { _ in } ) + .environmentObject(featureModels.peerListModel) ) #expect(viewModel.hasUnreadMessages(for: blockedPeer)) @@ -229,10 +378,11 @@ struct ViewSmokeTests { @Test func commandSuggestionsAndLocationViews_render() { let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let channel = GeohashChannel(level: .city, geohash: "u4pruy") var messageText = "/f" - LocationChannelManager.shared.select(.location(channel)) + featureModels.locationChannelsModel.select(.location(channel)) _ = mount( CommandSuggestionsView( @@ -244,22 +394,25 @@ struct ViewSmokeTests { backgroundColor: .black, secondaryTextColor: .gray ) - .environmentObject(viewModel) + .environmentObject(featureModels.privateConversationModel) + .environmentObject(featureModels.locationChannelsModel) ) _ = mount( LocationChannelsSheet(isPresented: .constant(true)) - .environmentObject(viewModel) + .environmentObject(featureModels.locationChannelsModel) + .environmentObject(featureModels.peerListModel) ) #expect(messageText == "/f") - LocationChannelManager.shared.select(.mesh) - LocationChannelManager.shared.endLiveRefresh() + featureModels.locationChannelsModel.select(.mesh) + featureModels.locationChannelsModel.endLiveRefresh() } @Test func locationNotesView_rendersNoRelayAndLoadedStates() throws { let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let noRelayManager = LocationNotesManager( geohash: "u4pruydq", @@ -302,12 +455,20 @@ struct ViewSmokeTests { eose?() _ = mount( - LocationNotesView(geohash: "u4pruydq", manager: noRelayManager) - .environmentObject(viewModel) + LocationNotesView( + geohash: "u4pruydq", + senderNickname: viewModel.nickname, + manager: noRelayManager + ) + .environmentObject(featureModels.locationChannelsModel) ) _ = mount( - LocationNotesView(geohash: "u4pruydq", manager: loadedManager) - .environmentObject(viewModel) + LocationNotesView( + geohash: "u4pruydq", + senderNickname: viewModel.nickname, + manager: loadedManager + ) + .environmentObject(featureModels.locationChannelsModel) ) #expect(loadedManager.notes.count == 1) @@ -350,11 +511,34 @@ struct ViewSmokeTests { #expect(PaymentChipView.PaymentType.lightning("lightning:lnbc1test").url?.scheme == "lightning") } + @Test + func contentShellViews_renderPublicAndPrivateBranches() async { + let (viewModel, transport, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) + let peerID = PeerID(str: "5152535455565758") + + transport.updatePeerSnapshots([ + makeSnapshot(peerID: peerID, nickname: "Alice", noiseByte: 0x66) + ]) + try? await Task.sleep(nanoseconds: 50_000_000) + + _ = mount(installSmokeEnvironment(ContentView(), featureModels: featureModels)) + _ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels)) + + featureModels.privateConversationModel.startConversation(with: peerID) + try? await Task.sleep(nanoseconds: 50_000_000) + + _ = mount(installSmokeEnvironment(ContentPeopleSheetHarness(), featureModels: featureModels)) + + #expect(featureModels.privateConversationModel.selectedPeerID == peerID) + #expect(featureModels.privateConversationModel.selectedHeaderState?.headerPeerID == peerID) + } + @Test func geohashAndTextMessageViews_renderCoreBranches() { let (viewModel, _, _) = makeSmokeViewModel() + let featureModels = makeSmokeFeatureModels(for: viewModel) let geohashPeopleList = GeohashPeopleList( - viewModel: viewModel, textColor: .green, secondaryTextColor: .gray, onTapPerson: {} @@ -377,10 +561,10 @@ struct ViewSmokeTests { deliveryStatus: .partiallyDelivered(reached: 1, total: 2) ) - _ = geohashPeopleList.body - _ = mount(geohashPeopleList) - _ = mount(TextMessageView(message: truncatableMessage).environmentObject(viewModel)) - _ = mount(TextMessageView(message: paymentMessage).environmentObject(viewModel)) + _ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel)) + _ = mount(geohashPeopleList.environmentObject(featureModels.peerListModel)) + _ = mount(TextMessageView(message: truncatableMessage).environmentObject(featureModels.conversationUIModel)) + _ = mount(TextMessageView(message: paymentMessage).environmentObject(featureModels.conversationUIModel)) #expect(truncatableMessage.content.count > TransportConfig.uiLongMessageLengthThreshold) #expect(paymentMessage.content.contains("lightning:") && paymentMessage.content.contains("cashu")) diff --git a/docs/ARCHITECTURE_V2.md b/docs/ARCHITECTURE_V2.md new file mode 100644 index 00000000..6b2c1037 --- /dev/null +++ b/docs/ARCHITECTURE_V2.md @@ -0,0 +1,40 @@ +# Architecture V2 + +This branch starts a larger simplification effort focused on performance, reliability, and maintainability without rewriting the transport and protocol stack in one pass. + +## What Landed + +1. `AppRuntime` is now the composition root for startup, lifecycle, notification routing, and shared-content intake. +2. `BitchatApp` is reduced to app wiring and no longer owns operational startup logic. +3. `AppEventStream` introduces a typed async event surface for app-level events. +4. `PeerHandle` and `ConversationID` establish canonical identity/conversation keys above the existing transport-specific IDs. +5. `IdentityResolver` mirrors the current peer graph into canonical handles. +6. `ConversationStore` now owns app-layer conversation context for the active public channel and selected private conversation, while also mirroring the current public/private message timelines under canonical IDs. +7. `AppRuntime` now owns Tor, screenshot, and Nostr reconnect observer wiring instead of leaving that app-lifecycle graph inside `ChatViewModel`. +8. `PublicChatModel`, `PrivateInboxModel`, `PrivateConversationModel`, `ConversationUIModel`, `LocationChannelsModel`, and `PeerListModel` provide focused view-facing models for public chat, private inbox state, selected-DM state, composer/message interactions, location-channel state, and peer counts. `PrivateInboxModel` now reads its message, unread, and selection state directly from `ConversationStore` instead of merging through `PrivateChatManager`. +9. `AppChromeModel` now owns app-chrome presentation state for nickname editing, fingerprint routing, app-info/location sheet visibility, unread-private-chat affordances, and screenshot/privacy UI. +10. `ContentView`, `MessageListView`, `LocationChannelsSheet`, `LocationNotesView`, `CommandSuggestionsView`, `FingerprintView`, and `VerificationSheetView` no longer talk directly to `ChatViewModel` or location singletons for their primary app-layer state; they consume feature-scoped models injected from `AppRuntime`. +11. `PeerListModel` now consumes the runtime-owned `LocationChannelsModel` instead of reading the location manager directly, and QR scan parsing is funneled through `VerificationModel` rather than the SwiftUI views. +12. `ChatViewModel` is thinner on the app side: startup wiring now lives in `ChatViewModelBootstrapper`, send/lifecycle/transport seams live in focused coordinators, and peer-list notification/timer state now lives in `ChatPeerListCoordinator` instead of the main view model. + +## Why This Shape + +- `ChatViewModel` remains the active domain/UI bridge so the app can keep working while we migrate feature-by-feature. +- The new runtime/store layer gives us a safe place to move lifecycle, routing, and cross-cutting state before we split the UI into smaller feature models. +- `ConversationStore` now owns the selected conversation context and private-inbox read state, which lets feature models read one runtime-managed source of truth for focus/navigation and DM timelines even while legacy mutation paths still flow through `ChatViewModel`. +- The new chrome model lets us remove presentation-only state from `ChatViewModel` without forcing a big rewrite of the domain/message paths. +- The private-conversation model pulls selected-DM header identity, availability, encryption, and favorite state out of `ContentView`, which keeps the view from re-implementing peer lookup rules. +- The conversation UI model centralizes composer state, autocomplete, message formatting, and row-level actions so the main views no longer proxy most conversation interactions directly to `ChatViewModel`. +- The location channels model gives the remaining location-heavy views a focused adapter over the location/bookmark/network singletons, which reduces direct global reads in the view layer and makes smoke/architecture tests more reliable. +- Reusing that same location model inside the peer-list feature keeps geohash/mesh presence presentation on one runtime-owned source of truth instead of mixing view models with singleton reads. +- `BitchatApp` now injects only feature-scoped models into the view tree, which removes the old pattern of keeping `ChatViewModel` as a global environment object for the entire app shell. +- Routing QR scan parsing through the verification model keeps the verification UI as a passive renderer and gives us a clearer seam for future camera/manual-scan behavior changes. +- The new `ChatViewModel` coordinators are intentionally transitional: they shrink the main file and isolate composition, lifecycle, transport, and peer-list responsibilities without forcing a risky rewrite of the transport/BLE core in the same pass. + +## Next Steps + +1. Migrate view composition from one global `ChatViewModel` to focused feature models backed by `ConversationStore`. +2. Route more inbound transport events through typed app/domain events instead of delegate plus notification fan-out. +3. Move private/public conversation mutation behind the new store instead of still mirroring legacy message writes from `ChatViewModel`. +4. Replace remaining singleton-heavy seams with injected runtime services where practical. +5. Revisit actor isolation for identity and conversation state once the remaining message/peer models are safe to move off the main actor.