diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 7489094b..a8c086f8 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -8,6 +8,8 @@ enum MessageType: UInt8 { case relay = 0x04 case announce = 0x05 case keyExchange = 0x06 + case leave = 0x07 + case privateMessage = 0x08 } struct BitchatPacket: Codable { @@ -40,21 +42,27 @@ struct BitchatPacket: Codable { } } -struct BitchatMessage: Codable { +struct BitchatMessage: Codable, Equatable { let id: String let sender: String let content: String let timestamp: Date let isRelay: Bool let originalSender: String? + let isPrivate: Bool + let recipientNickname: String? + let senderPeerID: String? - init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil) { + init(sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil) { self.id = UUID().uuidString self.sender = sender self.content = content self.timestamp = timestamp self.isRelay = isRelay self.originalSender = originalSender + self.isPrivate = isPrivate + self.recipientNickname = recipientNickname + self.senderPeerID = senderPeerID } } diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 178dc9d8..c7fcf9f2 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -1,6 +1,11 @@ import Foundation import CoreBluetooth import Combine +#if os(macOS) +import AppKit +#else +import UIKit +#endif class BluetoothMeshService: NSObject { static let serviceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C") @@ -21,6 +26,9 @@ class BluetoothMeshService: NSObject { private let messageQueue = DispatchQueue(label: "bitchat.messageQueue", attributes: .concurrent) private var processedMessages = Set() private let maxTTL: UInt8 = 5 + private var hasAnnounced = false + private var announcementTimer: Timer? + private var announcedPeers = Set() // Track peers who have already been announced let myPeerID: String @@ -30,6 +38,69 @@ class BluetoothMeshService: NSObject { centralManager = CBCentralManager(delegate: self, queue: nil) peripheralManager = CBPeripheralManager(delegate: self, queue: nil) + + // Register for app termination notifications + #if os(macOS) + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillTerminate), + name: NSApplication.willTerminateNotification, + object: nil + ) + #else + NotificationCenter.default.addObserver( + self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + #endif + } + + deinit { + cleanup() + } + + @objc private func appWillTerminate() { + cleanup() + } + + private func cleanup() { + print("[DEBUG] Cleaning up Bluetooth connections...") + + // Send leave announcement before disconnecting + sendLeaveAnnouncement() + + // Give the leave message time to send + Thread.sleep(forTimeInterval: 0.2) + + // First, disconnect all peripherals which will trigger disconnect delegates + for (_, peripheral) in connectedPeripherals { + centralManager.cancelPeripheralConnection(peripheral) + } + + // Stop advertising + if peripheralManager.isAdvertising { + peripheralManager.stopAdvertising() + } + + // Stop scanning + centralManager.stopScan() + + // Remove all services - this will disconnect any connected centrals + if peripheralManager.state == .poweredOn { + peripheralManager.removeAllServices() + } + + // Clear all tracking + connectedPeripherals.removeAll() + subscribedCentrals.removeAll() + activePeers.removeAll() + announcedPeers.removeAll() + + // Cancel announcement timer + announcementTimer?.invalidate() + announcementTimer = nil } func startServices() { @@ -45,6 +116,12 @@ class BluetoothMeshService: NSObject { setupPeripheral() startAdvertising() } + + // Schedule initial announcement after services are ready + announcementTimer?.invalidate() + announcementTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in + self?.sendInitialAnnouncement() + } } func startAdvertising() { @@ -117,30 +194,106 @@ class BluetoothMeshService: NSObject { } } + func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String) { + messageQueue.async { [weak self] in + guard let self = self else { return } + + let nickname = self.delegate as? ChatViewModel + let senderNick = nickname?.nickname ?? self.myPeerID + + let message = BitchatMessage( + sender: senderNick, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: recipientNickname, + senderPeerID: self.myPeerID + ) + + if let messageData = try? JSONEncoder().encode(message) { + let packet = BitchatPacket( + type: MessageType.privateMessage.rawValue, + senderID: self.myPeerID.data(using: .utf8)!, + recipientID: recipientPeerID.data(using: .utf8), + timestamp: UInt64(Date().timeIntervalSince1970), + payload: messageData, + signature: try? self.encryptionService.sign(messageData), + ttl: self.maxTTL + ) + + print("[DEBUG] Sending private message to \(recipientNickname): \(content)") + self.broadcastPacket(packet) + + // Also show the message locally + DispatchQueue.main.async { + self.delegate?.didReceiveMessage(message) + } + } + } + } + + private func sendInitialAnnouncement() { + guard let vm = delegate as? ChatViewModel else { return } + + print("[DEBUG] Sending initial announcement to all peers") + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: myPeerID.data(using: .utf8)!, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970), + payload: vm.nickname.data(using: .utf8)!, + signature: nil, + ttl: maxTTL + ) + + broadcastPacket(packet) + hasAnnounced = true + } + + private func sendLeaveAnnouncement() { + guard let vm = delegate as? ChatViewModel else { return } + + print("[DEBUG] Sending leave announcement") + let packet = BitchatPacket( + type: MessageType.leave.rawValue, + senderID: myPeerID.data(using: .utf8)!, + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970), + payload: vm.nickname.data(using: .utf8)!, + signature: nil, + ttl: 1 // Don't relay leave messages + ) + + broadcastPacket(packet) + } + func getPeerNicknames() -> [String: String] { return peerNicknames } private func getAllConnectedPeerIDs() -> [String] { - var peers = Set() - - // Include connected peripherals (devices we connected to as central) - peers.formUnion(connectedPeripherals.keys) - - // Include active peers (devices that have announced) - peers.formUnion(activePeers) - - // Filter out invalid peers - return peers.filter { $0 != "unknown" && $0 != myPeerID } + // Return all active peers, even if they haven't announced yet + let uniquePeers = Set(activePeers.filter { peerID in + peerID != "unknown" && peerID != myPeerID + }) + return Array(uniquePeers).sorted() } private func broadcastPacket(_ packet: BitchatPacket) { - guard let data = packet.data else { return } + guard let data = packet.data else { + print("[DEBUG] Failed to encode packet data") + return + } + + print("[DEBUG] Broadcasting packet type \(packet.type) to \(connectedPeripherals.count) peripherals and \(subscribedCentrals.count) centrals") // Send to connected peripherals (as central) - for (_, peripheral) in connectedPeripherals { + for (peerID, peripheral) in connectedPeripherals { if let characteristic = peripheralCharacteristics[peripheral] { + print("[DEBUG] Sending packet type \(packet.type) to peripheral \(peerID)") // Use withResponse for larger data for reliability let writeType: CBCharacteristicWriteType = data.count > 50000 ? .withResponse : .withoutResponse peripheral.writeValue(data, for: characteristic, type: writeType) @@ -149,7 +302,11 @@ class BluetoothMeshService: NSObject { // Send to subscribed centrals (as peripheral) if characteristic != nil && !subscribedCentrals.isEmpty { - peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: subscribedCentrals) + print("[DEBUG] Sending packet type \(packet.type) to \(subscribedCentrals.count) subscribed centrals") + let success = peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: subscribedCentrals) + if !success { + print("[DEBUG] Failed to send to centrals - queue full") + } } } @@ -164,7 +321,25 @@ class BluetoothMeshService: NSObject { processedMessages.removeAll() } - print("[DEBUG] Received packet type: \(packet.type) from \(peerID)") + let senderID = String(data: packet.senderID, encoding: .utf8) ?? "unknown" + print("[DEBUG] Received packet type: \(packet.type) from peerID: \(peerID), senderID: \(senderID)") + + // For any message type, if we have a nickname in the payload, update it immediately + if packet.type == MessageType.message.rawValue || packet.type == MessageType.privateMessage.rawValue { + if let message = try? JSONDecoder().decode(BitchatMessage.self, from: packet.payload), + senderID != "unknown" && senderID != myPeerID { + // Update nickname mapping immediately + if peerNicknames[senderID] != message.sender { + peerNicknames[senderID] = message.sender + print("[DEBUG] Updated nickname for \(senderID): \(message.sender) from message") + + // Update peer list to show the new nickname + DispatchQueue.main.async { + self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) + } + } + } + } switch MessageType(rawValue: packet.type) { case .message: @@ -191,27 +366,32 @@ class BluetoothMeshService: NSObject { } case .keyExchange: - print("[DEBUG] Received key exchange from \(peerID)") - if let publicKeyData = try? JSONDecoder().decode(Data.self, from: packet.payload) { - try? encryptionService.addPeerPublicKey(peerID, publicKeyData: publicKeyData) - - // Track this peer temporarily but don't announce until we get their name - if peerID != "unknown" && peerID != myPeerID && !peerNicknames.keys.contains(peerID) { - // Just track them, don't announce yet - print("[DEBUG] Tracking peer \(peerID) after key exchange") - DispatchQueue.main.async { - self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) + // Use senderID from packet for consistency + if let senderID = String(data: packet.senderID, encoding: .utf8) { + print("[DEBUG] Received key exchange from \(senderID)") + if let publicKeyData = try? JSONDecoder().decode(Data.self, from: packet.payload) { + try? encryptionService.addPeerPublicKey(senderID, publicKeyData: publicKeyData) + + // Track this peer temporarily + if senderID != "unknown" && senderID != myPeerID { + // Add to active peers immediately on key exchange + activePeers.insert(senderID) + print("[DEBUG] Added peer \(senderID) to active peers after key exchange") + print("[DEBUG] Active peers now: \(activePeers)") + let connectedPeerIDs = self.getAllConnectedPeerIDs() + print("[DEBUG] Connected peer IDs: \(connectedPeerIDs)") + DispatchQueue.main.async { + self.delegate?.didUpdatePeerList(connectedPeerIDs) + } } - } - - // Send announce with our nickname immediately - DispatchQueue.main.async { [weak self] in - if let self = self, let vm = self.delegate as? ChatViewModel { - print("[DEBUG] Sending announce to \(peerID)") + + // Send announce with our nickname immediately + if let vm = self.delegate as? ChatViewModel { + print("[DEBUG] Sending announce to \(senderID)") let announcePacket = BitchatPacket( type: MessageType.announce.rawValue, senderID: self.myPeerID.data(using: .utf8)!, - recipientID: peerID.data(using: .utf8), + recipientID: senderID.data(using: .utf8), timestamp: UInt64(Date().timeIntervalSince1970), payload: vm.nickname.data(using: .utf8)!, signature: nil, @@ -223,24 +403,32 @@ class BluetoothMeshService: NSObject { } case .announce: - if let nickname = String(data: packet.payload, encoding: .utf8) { - print("[DEBUG] Received announce from \(peerID): \(nickname)") + if let nickname = String(data: packet.payload, encoding: .utf8), + let senderID = String(data: packet.senderID, encoding: .utf8) { + print("[DEBUG] Received announce from \(senderID): \(nickname)") - // Check if this is the first time we're getting a nickname for this peer - let isNewNickname = peerNicknames[peerID] == nil + // Ignore if it's from ourselves + if senderID == myPeerID { + return + } + + // Check if we've already announced this peer + let isFirstAnnounce = !announcedPeers.contains(senderID) // Store the nickname - peerNicknames[peerID] = nickname + peerNicknames[senderID] = nickname + print("[DEBUG] Stored nickname for \(senderID): \(nickname)") // Add to active peers if not already there - if peerID != "unknown" && peerID != myPeerID { - if !activePeers.contains(peerID) { - print("[DEBUG] Adding new peer \(peerID) to active peers") - activePeers.insert(peerID) + if senderID != "unknown" { + if !activePeers.contains(senderID) { + print("[DEBUG] Adding new peer \(senderID) to active peers") + activePeers.insert(senderID) } - // Show join message if this is a new nickname - if isNewNickname { + // Show join message only for first announce + if isFirstAnnounce { + announcedPeers.insert(senderID) DispatchQueue.main.async { self.delegate?.didConnectToPeer(nickname) self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) @@ -252,7 +440,77 @@ class BluetoothMeshService: NSObject { } } } else { - print("[DEBUG] Peer \(peerID) is invalid (unknown or self)") + print("[DEBUG] Peer \(senderID) is invalid (unknown)") + } + } + + case .leave: + if let nickname = String(data: packet.payload, encoding: .utf8), + let senderID = String(data: packet.senderID, encoding: .utf8) { + print("[DEBUG] Received leave from \(senderID): \(nickname)") + + // Remove from active peers + activePeers.remove(senderID) + announcedPeers.remove(senderID) + + // Show leave message + DispatchQueue.main.async { + self.delegate?.didDisconnectFromPeer(nickname) + self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) + } + + // Clean up peer data + peerNicknames.removeValue(forKey: senderID) + } + + case .privateMessage: + if let message = try? JSONDecoder().decode(BitchatMessage.self, from: packet.payload) { + // Check if this private message is for us + if let recipientID = packet.recipientID, + let recipientIDString = String(data: recipientID, encoding: .utf8), + recipientIDString == myPeerID { + + // Get sender ID + if let senderID = String(data: packet.senderID, encoding: .utf8) { + // Ignore our own messages + if senderID == myPeerID { + return + } + + // Store nickname mapping if we don't have it + if peerNicknames[senderID] == nil { + peerNicknames[senderID] = message.sender + print("[DEBUG] Updated nickname for \(senderID): \(message.sender)") + + // Update peer list to show the new nickname + DispatchQueue.main.async { + self.delegate?.didUpdatePeerList(self.getAllConnectedPeerIDs()) + } + } + + print("[DEBUG] Received private message from \(message.sender) (peer: \(senderID))") + + // Create a new message with the sender peer ID + let messageWithPeerID = BitchatMessage( + sender: message.sender, + content: message.content, + timestamp: message.timestamp, + isRelay: message.isRelay, + originalSender: message.originalSender, + isPrivate: message.isPrivate, + recipientNickname: message.recipientNickname, + senderPeerID: senderID + ) + + DispatchQueue.main.async { + self.delegate?.didReceiveMessage(messageWithPeerID) + } + } + } else if packet.ttl > 0 { + // Relay private messages that aren't for us + var relayPacket = packet + relayPacket.ttl -= 1 + self.broadcastPacket(relayPacket) } } @@ -267,6 +525,14 @@ extension BluetoothMeshService: CBCentralManagerDelegate { print("[DEBUG] Central state changed to: \(central.state.rawValue)") if central.state == .poweredOn { startScanning() + + // If we haven't announced yet and we're now powered on, schedule announcement + if !hasAnnounced { + announcementTimer?.invalidate() + announcementTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in + self?.sendInitialAnnouncement() + } + } } } @@ -287,13 +553,15 @@ extension BluetoothMeshService: CBCentralManagerDelegate { peripheral.delegate = self peripheral.discoverServices([BluetoothMeshService.serviceUUID]) - // Extract peer ID from advertisement or generate one - var peerID = peripheral.identifier.uuidString.prefix(8).lowercased() + // Extract peer ID from advertisement or use peripheral identifier + let peerID: String if let name = peripheral.name, name.hasPrefix("bitchat-") { peerID = String(name.dropFirst(8)) + } else { + peerID = peripheral.identifier.uuidString.prefix(8).lowercased() } - connectedPeripherals[String(peerID)] = peripheral + connectedPeripherals[peerID] = peripheral print("[DEBUG] Connected to peer: \(peerID)") print("[DEBUG] Active peers: \(activePeers)") @@ -312,6 +580,7 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Remove from active peers activePeers.remove(peerID) + announcedPeers.remove(peerID) // Only show disconnect if we have a resolved nickname if let nickname = peerNicknames[peerID], nickname != peerID { @@ -331,7 +600,8 @@ extension BluetoothMeshService: CBCentralManagerDelegate { // Continue scanning for reconnection if centralManager.state == .poweredOn { - // Always restart scan to ensure we can reconnect + print("[DEBUG] Restarting scan after disconnect") + // Stop and restart to ensure clean state centralManager.stopScan() centralManager.scanForPeripherals(withServices: [BluetoothMeshService.serviceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) } @@ -352,11 +622,12 @@ extension BluetoothMeshService: CBPeripheralDelegate { for characteristic in characteristics { if characteristic.uuid == BluetoothMeshService.characteristicUUID { + print("[DEBUG] Found characteristic, subscribing to notifications...") peripheral.setNotifyValue(true, for: characteristic) peripheralCharacteristics[peripheral] = characteristic - // Wait a moment for subscription to complete before sending data - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + // Send key exchange immediately + DispatchQueue.main.async { [weak self] in guard let self = self else { return } let publicKeyData = self.encryptionService.publicKey.rawRepresentation @@ -374,9 +645,9 @@ extension BluetoothMeshService: CBPeripheralDelegate { peripheral.writeValue(data, for: characteristic, type: .withResponse) } - // Also send announce packet immediately + // Send announce packet immediately after key exchange if let vm = self.delegate as? ChatViewModel { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } let announcePacket = BitchatPacket( type: MessageType.announce.rawValue, @@ -416,7 +687,11 @@ extension BluetoothMeshService: CBPeripheralDelegate { } func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { - // Handle notification state update if needed + if let error = error { + print("[DEBUG] Error updating notification state: \(error)") + } else { + print("[DEBUG] Notification state updated for characteristic: \(characteristic.isNotifying)") + } } } @@ -428,6 +703,14 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { print("[DEBUG] Peripheral powered on, setting up...") setupPeripheral() startAdvertising() + + // If we haven't announced yet and we're now powered on, schedule announcement + if !hasAnnounced { + announcementTimer?.invalidate() + announcementTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in + self?.sendInitialAnnouncement() + } + } default: break } @@ -469,10 +752,11 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central]) } - // Also send announce immediately after key exchange + // Send announce immediately after key exchange if let vm = self.delegate as? ChatViewModel { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } + print("[DEBUG] Sending announce packet to central after key exchange") let announcePacket = BitchatPacket( type: MessageType.announce.rawValue, senderID: self.myPeerID.data(using: .utf8)!, @@ -483,7 +767,8 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { ttl: 1 ) if let data = announcePacket.data { - peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [request.central]) + let success = peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: nil) + print("[DEBUG] Announce sent to all centrals: \(success)") } } } @@ -521,9 +806,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { if let data = keyPacket.data { peripheral.updateValue(data, for: self.characteristic, onSubscribedCentrals: [central]) - // Also send announce after key exchange + // Send announce immediately after key exchange if let vm = delegate as? ChatViewModel { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } let announcePacket = BitchatPacket( type: MessageType.announce.rawValue, diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 40d44f94..536c58c6 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -17,6 +17,9 @@ class ChatViewModel: ObservableObject { } } @Published var isConnected = false + @Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages + @Published var selectedPrivateChatPeer: String? = nil + @Published var unreadPrivateMessages: Set = [] let meshService = BluetoothMeshService() private let userDefaults = UserDefaults.standard @@ -48,18 +51,54 @@ class ChatViewModel: ObservableObject { func sendMessage(_ content: String) { guard !content.isEmpty else { return } - // Add message to local display - let message = BitchatMessage( - sender: nickname, - content: content, - timestamp: Date(), - isRelay: false, - originalSender: nil - ) - messages.append(message) + if let selectedPeer = selectedPrivateChatPeer { + // Send as private message + sendPrivateMessage(content, to: selectedPeer) + } else { + // Add message to local display + let message = BitchatMessage( + sender: nickname, + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil + ) + messages.append(message) + + // Send via mesh + meshService.sendMessage(content) + } + } + + func sendPrivateMessage(_ content: String, to peerID: String) { + guard !content.isEmpty else { return } + guard let recipientNickname = meshService.getPeerNicknames()[peerID] else { return } // Send via mesh - meshService.sendMessage(content) + meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname) + } + + func startPrivateChat(with peerID: String) { + selectedPrivateChatPeer = peerID + unreadPrivateMessages.remove(peerID) + + // Initialize chat history if needed + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + } + + func endPrivateChat() { + selectedPrivateChatPeer = nil + } + + func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] { + return privateChats[peerID] ?? [] + } + + func getPeerIDForNickname(_ nickname: String) -> String? { + let nicknames = meshService.getPeerNicknames() + return nicknames.first(where: { $0.value == nickname })?.key } @@ -86,7 +125,7 @@ class ChatViewModel: ObservableObject { let content = AttributedString("* \(message.content) *") var contentStyle = AttributeContainer() contentStyle.foregroundColor = secondaryColor - contentStyle.font = .system(size: 12, design: .monospaced).italic() + contentStyle.font = .system(size: 14, design: .monospaced).italic() result.append(content.mergingAttributes(contentStyle)) } else { let sender = AttributedString("<\(message.sender)> ") @@ -116,7 +155,39 @@ class ChatViewModel: ObservableObject { extension ChatViewModel: BitchatDelegate { func didReceiveMessage(_ message: BitchatMessage) { - messages.append(message) + if message.isPrivate { + // Handle private message + print("[DEBUG] Received private message from \(message.sender)") + + // Use the senderPeerID from the message if available + let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) + + if let peerID = senderPeerID { + // Message from someone else + if privateChats[peerID] == nil { + privateChats[peerID] = [] + } + privateChats[peerID]?.append(message) + + // Mark as unread if not currently viewing this chat + if selectedPrivateChatPeer != peerID { + unreadPrivateMessages.insert(peerID) + print("[DEBUG] Added unread message indicator for peer: \(peerID)") + } + } else if message.sender == nickname { + // Our own message - find recipient by nickname + if let recipientNickname = message.recipientNickname, + let recipientPeerID = getPeerIDForNickname(recipientNickname) { + if privateChats[recipientPeerID] == nil { + privateChats[recipientPeerID] = [] + } + privateChats[recipientPeerID]?.append(message) + } + } + } else { + // Regular public message + messages.append(message) + } #if os(iOS) // Haptic feedback for new messages @@ -129,7 +200,7 @@ extension ChatViewModel: BitchatDelegate { isConnected = true let systemMessage = BitchatMessage( sender: "system", - content: "\(peerID) has joined", + content: "\(peerID) has joined the channel", timestamp: Date(), isRelay: false, originalSender: nil @@ -140,7 +211,7 @@ extension ChatViewModel: BitchatDelegate { func didDisconnectFromPeer(_ peerID: String) { let systemMessage = BitchatMessage( sender: "system", - content: "\(peerID) has left", + content: "\(peerID) has left the channel", timestamp: Date(), isRelay: false, originalSender: nil @@ -157,5 +228,12 @@ extension ChatViewModel: BitchatDelegate { if peers.isEmpty && isConnected { isConnected = false } + + // If we're in a private chat with someone who disconnected, exit the chat + if let currentChatPeer = selectedPrivateChatPeer, + !peers.contains(currentChatPeer) { + print("[DEBUG] Private chat peer disconnected, exiting private chat") + endPrivateChat() + } } } \ No newline at end of file diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 9b21a8ae..54d5bd65 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -36,66 +36,71 @@ struct ContentView: View { private var headerView: some View { HStack { - Text("bitchat") - .font(.system(size: 18, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - - Spacer() - - Menu { - if viewModel.connectedPeers.isEmpty { - Text("No peers connected") - .font(.system(size: 12, design: .monospaced)) - } else { - let peerNicknames = viewModel.meshService.getPeerNicknames() - ForEach(viewModel.connectedPeers, id: \.self) { peerID in - if let displayName = peerNicknames[peerID], displayName != peerID { - // Only show if we have a real nickname - Label(displayName, systemImage: "person.fill") - .font(.system(size: 12, design: .monospaced)) + if let privatePeerID = viewModel.selectedPrivateChatPeer, + let privatePeerNick = viewModel.meshService.getPeerNicknames()[privatePeerID] { + // Private chat header + HStack { + Button(action: { + viewModel.endPrivateChat() + }) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("back") + .font(.system(size: 14, design: .monospaced)) } + .foregroundColor(textColor) } - } - } label: { - HStack(spacing: 4) { - Circle() - .fill(viewModel.isConnected ? textColor : Color.red) - .frame(width: 8, height: 8) + .buttonStyle(.plain) - HStack(spacing: 0) { - Text(viewModel.isConnected ? "\(viewModel.connectedPeers.count) \(viewModel.connectedPeers.count == 1 ? "peer" : "peers")" : "Scanning...") + Spacer() + + Text("private: \(privatePeerNick)") + .font(.system(size: 16, weight: .medium, design: .monospaced)) + .foregroundColor(Color.orange) + .frame(maxWidth: .infinity) + + Spacer() + + // Invisible spacer to balance the back button + HStack(spacing: 4) { + Image(systemName: "chevron.left") + .font(.system(size: 12)) + Text("back") .font(.system(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - Text(Image(systemName: "chevron.down")) - .font(.system(size: 10)) - .foregroundColor(secondaryTextColor) - .baselineOffset(-1) } + .opacity(0) } - .contentShape(Rectangle()) // Make entire area tappable - } - .buttonStyle(.plain) - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - - Spacer() - - HStack(spacing: 4) { - Text("nick:") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(secondaryTextColor) - - TextField("nickname", text: $viewModel.nickname) - .textFieldStyle(.plain) - .font(.system(size: 12, design: .monospaced)) - .frame(maxWidth: 100) + } else { + // Public chat header + Text("bitchat") + .font(.system(size: 18, weight: .medium, design: .monospaced)) .foregroundColor(textColor) - .onChange(of: viewModel.nickname) { _ in - viewModel.saveNickname() - } - .onSubmit { - viewModel.saveNickname() - } + + Spacer() + + // Peer status section + peerStatusView + + Spacer() + + HStack(spacing: 4) { + Text("nick:") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(secondaryTextColor) + + TextField("nickname", text: $viewModel.nickname) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: 100) + .foregroundColor(textColor) + .onChange(of: viewModel.nickname) { _ in + viewModel.saveNickname() + } + .onSubmit { + viewModel.saveNickname() + } + } } } .padding(.horizontal, 12) @@ -103,11 +108,71 @@ struct ContentView: View { .background(backgroundColor.opacity(0.95)) } + private var peerStatusView: some View { + Menu { + if viewModel.connectedPeers.isEmpty { + Text("No peers connected") + .font(.system(size: 12, design: .monospaced)) + } else { + let peerNicknames = viewModel.meshService.getPeerNicknames() + let myPeerID = viewModel.meshService.myPeerID + ForEach(viewModel.connectedPeers.filter { $0 != myPeerID }.sorted(), id: \.self) { peerID in + let displayName = peerNicknames[peerID] ?? "peer-\(peerID.prefix(4))" + Button(action: { + // Only allow private chat if peer has announced + if peerNicknames[peerID] != nil { + viewModel.startPrivateChat(with: peerID) + } + }) { + HStack { + Text(displayName) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(peerNicknames[peerID] != nil ? textColor : secondaryTextColor) + Spacer() + if viewModel.unreadPrivateMessages.contains(peerID) { + Circle() + .fill(Color.orange) + .frame(width: 6, height: 6) + } + } + } + .buttonStyle(.plain) + .disabled(peerNicknames[peerID] == nil) + } + } + } label: { + HStack(spacing: 4) { + // Notification indicator for unread messages + if !viewModel.unreadPrivateMessages.isEmpty { + Circle() + .fill(Color.orange) + .frame(width: 8, height: 8) + } + + // Text + Text(viewModel.isConnected ? "\(viewModel.connectedPeers.count) \(viewModel.connectedPeers.count == 1 ? "peer" : "peers")" : "scanning") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(viewModel.isConnected ? textColor : Color.red) + + // Chevron + Image(systemName: "chevron.down") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor) + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + } + private var messagesView: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 2) { - ForEach(Array(viewModel.messages.enumerated()), id: \.offset) { index, message in + let messages = viewModel.selectedPrivateChatPeer != nil + ? viewModel.getPrivateChatMessages(for: viewModel.selectedPrivateChatPeer!) + : viewModel.messages + + ForEach(Array(messages.enumerated()), id: \.offset) { index, message in VStack(alignment: .leading, spacing: 4) { Text(viewModel.formatMessage(message, colorScheme: colorScheme)) .font(.system(size: 14, design: .monospaced)) @@ -124,8 +189,19 @@ struct ContentView: View { } .background(backgroundColor) .onChange(of: viewModel.messages.count) { _ in - withAnimation { - proxy.scrollTo(viewModel.messages.count - 1, anchor: .bottom) + if viewModel.selectedPrivateChatPeer == nil { + withAnimation { + proxy.scrollTo(viewModel.messages.count - 1, anchor: .bottom) + } + } + } + .onChange(of: viewModel.privateChats) { _ in + if let peerID = viewModel.selectedPrivateChatPeer, + let messages = viewModel.privateChats[peerID], + !messages.isEmpty { + withAnimation { + proxy.scrollTo(messages.count - 1, anchor: .bottom) + } } } } @@ -140,11 +216,19 @@ struct ContentView: View { .fixedSize() .padding(.leading, 12) - Text("<\(viewModel.nickname)>") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - .lineLimit(1) - .fixedSize() + if viewModel.selectedPrivateChatPeer != nil { + Text("<\(viewModel.nickname)> →") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(Color.orange) + .lineLimit(1) + .fixedSize() + } else { + Text("<\(viewModel.nickname)>") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundColor(textColor) + .lineLimit(1) + .fixedSize() + } TextField("", text: $messageText) .textFieldStyle(.plain) @@ -157,7 +241,7 @@ struct ContentView: View { Button(action: sendMessage) { Image(systemName: "arrow.right.circle.fill") - .font(.system(size: 16)) + .font(.system(size: 20)) .foregroundColor(textColor) } .buttonStyle(.plain)