diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 9be8c266..8ea9dbbc 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -101,8 +101,8 @@ class BluetoothMeshService: NSObject { private var advertisingTimer: Timer? // Timer for interval-based advertising // Timing randomization for privacy - private let minMessageDelay: TimeInterval = 0.05 // 50ms minimum - private let maxMessageDelay: TimeInterval = 0.5 // 500ms maximum + private let minMessageDelay: TimeInterval = 0.01 // 10ms minimum for faster sync + private let maxMessageDelay: TimeInterval = 0.1 // 100ms maximum for faster sync // Fragment handling private var incomingFragments: [String: [Int: Data]] = [:] // fragmentID -> [index: data] @@ -372,7 +372,7 @@ class BluetoothMeshService: NSObject { } // Send initial announces after services are ready - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.sendBroadcastAnnounce() } @@ -401,7 +401,7 @@ class BluetoothMeshService: NSObject { } // Send multiple times for reliability with jittered delays - for baseDelay in [0.5, 1.0, 2.0] { + for baseDelay in [0.2, 0.5, 1.0] { let jitteredDelay = baseDelay + self.randomDelay() DispatchQueue.main.asyncAfter(deadline: .now() + jitteredDelay) { [weak self] in guard let self = self else { return } @@ -495,10 +495,9 @@ class BluetoothMeshService: NSObject { self.characteristic = characteristic } - func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil) { + func sendMessage(_ content: String, mentions: [String] = [], channel: String? = nil, to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { // Defensive check for empty content guard !content.isEmpty else { return } - messageQueue.async { [weak self] in guard let self = self else { return } @@ -506,9 +505,10 @@ class BluetoothMeshService: NSObject { let senderNick = nickname?.nickname ?? self.myPeerID let message = BitchatMessage( + id: messageID, sender: senderNick, content: content, - timestamp: Date(), + timestamp: timestamp ?? Date(), isRelay: false, originalSender: nil, isPrivate: false, @@ -818,7 +818,7 @@ class BluetoothMeshService: NSObject { } } - func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey) { + func sendEncryptedChannelMessage(_ content: String, mentions: [String], channel: String, channelKey: SymmetricKey, messageID: String? = nil, timestamp: Date? = nil) { messageQueue.async { [weak self] in guard let self = self else { return } @@ -839,9 +839,10 @@ class BluetoothMeshService: NSObject { // Create message with encrypted content let message = BitchatMessage( + id: messageID, sender: senderNick, content: "", // Empty placeholder since actual content is encrypted - timestamp: Date(), + timestamp: timestamp ?? Date(), isRelay: false, originalSender: nil, isPrivate: false, @@ -1221,7 +1222,7 @@ class BluetoothMeshService: NSObject { // Send cached messages with slight delay between each for (index, storedMessage) in messagesToSend.enumerated() { - let delay = Double(index) * 0.1 // 100ms between messages + let delay = Double(index) * 0.02 // 20ms between messages for faster sync DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak peripheral] in guard let peripheral = peripheral, @@ -1275,8 +1276,10 @@ class BluetoothMeshService: NSObject { private func broadcastPacket(_ packet: BitchatPacket) { guard let data = packet.toBinaryData() else { // print("[ERROR] Failed to convert packet to binary data") - // Add to retry queue if this is a message packet - if packet.type == MessageType.message.rawValue, + // Add to retry queue if this is a message packet AND it's our own message + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), + senderID == self.myPeerID, + packet.type == MessageType.message.rawValue, let message = BitchatMessage.fromBinaryPayload(packet.payload) { MessageRetryService.shared.addMessageForRetry( content: message.content, @@ -1284,7 +1287,10 @@ class BluetoothMeshService: NSObject { channel: message.channel, isPrivate: message.isPrivate, recipientPeerID: nil, - recipientNickname: message.recipientNickname + recipientNickname: message.recipientNickname, + channelKey: nil, + originalMessageID: message.id, + originalTimestamp: message.timestamp ) } return @@ -1331,29 +1337,36 @@ class BluetoothMeshService: NSObject { } } - // If no peers received the message, add to retry queue + // If no peers received the message, add to retry queue ONLY if it's our own message if sentToPeripherals == 0 && sentToCentrals == 0 { - if packet.type == MessageType.message.rawValue, - let message = BitchatMessage.fromBinaryPayload(packet.payload) { - // For encrypted channel messages, we need to preserve the channel key - var channelKeyData: Data? = nil - if let channel = message.channel, message.isEncrypted { - // This is an encrypted channel message - if let viewModel = delegate as? ChatViewModel, - let channelKey = viewModel.channelKeys[channel] { - channelKeyData = channelKey.withUnsafeBytes { Data($0) } + // Check if this packet originated from us + if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8), + senderID == self.myPeerID { + // This is our own message that failed to send + if packet.type == MessageType.message.rawValue, + let message = BitchatMessage.fromBinaryPayload(packet.payload) { + // For encrypted channel messages, we need to preserve the channel key + var channelKeyData: Data? = nil + if let channel = message.channel, message.isEncrypted { + // This is an encrypted channel message + if let viewModel = delegate as? ChatViewModel, + let channelKey = viewModel.channelKeys[channel] { + channelKeyData = channelKey.withUnsafeBytes { Data($0) } + } } + + MessageRetryService.shared.addMessageForRetry( + content: message.content, + mentions: message.mentions, + channel: message.channel, + isPrivate: message.isPrivate, + recipientPeerID: nil, + recipientNickname: message.recipientNickname, + channelKey: channelKeyData, + originalMessageID: message.id, + originalTimestamp: message.timestamp + ) } - - MessageRetryService.shared.addMessageForRetry( - content: message.content, - mentions: message.mentions, - channel: message.channel, - isPrivate: message.isPrivate, - recipientPeerID: nil, - recipientNickname: message.recipientNickname, - channelKey: channelKeyData - ) } } } @@ -1759,11 +1772,9 @@ class BluetoothMeshService: NSObject { // Send announce with our nickname immediately self.sendAnnouncementToPeer(senderID) - // Delay sending cached messages to ensure connection is fully established - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - // Check if this peer has cached messages (especially for favorites) - self?.sendCachedMessages(to: senderID) - } + // Send cached messages immediately for faster sync + // Check if this peer has cached messages (especially for favorites) + self.sendCachedMessages(to: senderID) } } diff --git a/bitchat/Services/MessageRetryService.swift b/bitchat/Services/MessageRetryService.swift index 7d296887..16956414 100644 --- a/bitchat/Services/MessageRetryService.swift +++ b/bitchat/Services/MessageRetryService.swift @@ -12,6 +12,8 @@ import CryptoKit struct RetryableMessage { let id: String + let originalMessageID: String? + let originalTimestamp: Date? let content: String let mentions: [String]? let channel: String? @@ -29,7 +31,7 @@ class MessageRetryService { private var retryQueue: [RetryableMessage] = [] private var retryTimer: Timer? - private let retryInterval: TimeInterval = 5.0 // Retry every 5 seconds + private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync private let maxQueueSize = 50 weak var meshService: BluetoothMeshService? @@ -55,15 +57,34 @@ class MessageRetryService { isPrivate: Bool = false, recipientPeerID: String? = nil, recipientNickname: String? = nil, - channelKey: Data? = nil + channelKey: Data? = nil, + originalMessageID: String? = nil, + originalTimestamp: Date? = nil ) { + // Don't queue empty or whitespace-only messages + guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + // Don't queue if we're at capacity guard retryQueue.count < maxQueueSize else { return } + // Check if this message is already in the queue + if let messageID = originalMessageID { + let alreadyQueued = retryQueue.contains { msg in + msg.originalMessageID == messageID + } + if alreadyQueued { + return // Don't add duplicate + } + } + let retryMessage = RetryableMessage( id: UUID().uuidString, + originalMessageID: originalMessageID, + originalTimestamp: originalTimestamp, content: content, mentions: mentions, channel: channel, @@ -76,10 +97,17 @@ class MessageRetryService { ) retryQueue.append(retryMessage) + + // Sort the queue by original timestamp to maintain message order + retryQueue.sort { (msg1, msg2) in + let time1 = msg1.originalTimestamp ?? Date.distantPast + let time2 = msg2.originalTimestamp ?? Date.distantPast + return time1 < time2 + } } private func processRetryQueue() { - guard let meshService = meshService else { return } + guard meshService != nil else { return } let now = Date() var messagesToRetry: [RetryableMessage] = [] @@ -95,95 +123,123 @@ class MessageRetryService { retryQueue = updatedQueue - for message in messagesToRetry { + // Sort messages by original timestamp to maintain order + messagesToRetry.sort { (msg1, msg2) in + let time1 = msg1.originalTimestamp ?? Date.distantPast + let time2 = msg2.originalTimestamp ?? Date.distantPast + return time1 < time2 + } + + // Send messages with delay to maintain order + for (index, message) in messagesToRetry.enumerated() { // Check if we should still retry if message.retryCount >= message.maxRetries { continue } - // Check connectivity before retrying - let viewModel = meshService.delegate as? ChatViewModel - let connectedPeers = viewModel?.connectedPeers ?? [] + // Add delay between messages to ensure proper ordering + let delay = Double(index) * 0.05 // 50ms between messages - if message.isPrivate { - // For private messages, check if recipient is connected - if let recipientID = message.recipientPeerID, - connectedPeers.contains(recipientID) { - // Retry private message - meshService.sendPrivateMessage( - message.content, - to: recipientID, - recipientNickname: message.recipientNickname ?? "unknown" - ) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, + let meshService = self.meshService else { return } + + // Check connectivity before retrying + let viewModel = meshService.delegate as? ChatViewModel + let connectedPeers = viewModel?.connectedPeers ?? [] + + if message.isPrivate { + // For private messages, check if recipient is connected + if let recipientID = message.recipientPeerID, + connectedPeers.contains(recipientID) { + // Retry private message + meshService.sendPrivateMessage( + message.content, + to: recipientID, + recipientNickname: message.recipientNickname ?? "unknown", + messageID: message.originalMessageID + ) + } else { + // Recipient not connected, keep in queue with updated retry time + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, + content: message.content, + mentions: message.mentions, + channel: message.channel, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + channelKey: message.channelKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2)) + ) + self.retryQueue.append(updatedMessage) + } + } else if let channel = message.channel, let channelKeyData = message.channelKey { + // For channel messages, check if we have peers in the channel + if !connectedPeers.isEmpty { + // Recreate SymmetricKey from data + let channelKey = SymmetricKey(data: channelKeyData) + meshService.sendEncryptedChannelMessage( + message.content, + mentions: message.mentions ?? [], + channel: channel, + channelKey: channelKey, + messageID: message.originalMessageID, + timestamp: message.originalTimestamp + ) + } else { + // No peers connected, keep in queue + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, + content: message.content, + mentions: message.mentions, + channel: message.channel, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + channelKey: message.channelKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2)) + ) + self.retryQueue.append(updatedMessage) + } } else { - // Recipient not connected, keep in queue with updated retry time - var updatedMessage = message - updatedMessage = RetryableMessage( - id: message.id, - content: message.content, - mentions: message.mentions, - channel: message.channel, - isPrivate: message.isPrivate, - recipientPeerID: message.recipientPeerID, - recipientNickname: message.recipientNickname, - channelKey: message.channelKey, - retryCount: message.retryCount + 1, - nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) - ) - retryQueue.append(updatedMessage) - } - } else if let channel = message.channel, let channelKeyData = message.channelKey { - // For channel messages, check if we have peers in the channel - if !connectedPeers.isEmpty { - // Recreate SymmetricKey from data - let channelKey = SymmetricKey(data: channelKeyData) - meshService.sendEncryptedChannelMessage( - message.content, - mentions: message.mentions ?? [], - channel: channel, - channelKey: channelKey - ) - } else { - // No peers connected, keep in queue - var updatedMessage = message - updatedMessage = RetryableMessage( - id: message.id, - content: message.content, - mentions: message.mentions, - channel: message.channel, - isPrivate: message.isPrivate, - recipientPeerID: message.recipientPeerID, - recipientNickname: message.recipientNickname, - channelKey: message.channelKey, - retryCount: message.retryCount + 1, - nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) - ) - retryQueue.append(updatedMessage) - } - } else { - // Regular message - if !connectedPeers.isEmpty { - meshService.sendMessage( - message.content, - mentions: message.mentions ?? [], - channel: message.channel - ) - } else { - // No peers connected, keep in queue - var updatedMessage = message - updatedMessage = RetryableMessage( - id: message.id, - content: message.content, - mentions: message.mentions, - channel: message.channel, - isPrivate: message.isPrivate, - recipientPeerID: message.recipientPeerID, - recipientNickname: message.recipientNickname, - channelKey: message.channelKey, - retryCount: message.retryCount + 1, - nextRetryTime: Date().addingTimeInterval(retryInterval * Double(message.retryCount + 2)) - ) - retryQueue.append(updatedMessage) + // Regular message + if !connectedPeers.isEmpty { + meshService.sendMessage( + message.content, + mentions: message.mentions ?? [], + channel: message.channel, + to: nil, + messageID: message.originalMessageID, + timestamp: message.originalTimestamp + ) + } else { + // No peers connected, keep in queue + var updatedMessage = message + updatedMessage = RetryableMessage( + id: message.id, + originalMessageID: message.originalMessageID, + originalTimestamp: message.originalTimestamp, + content: message.content, + mentions: message.mentions, + channel: message.channel, + isPrivate: message.isPrivate, + recipientPeerID: message.recipientPeerID, + recipientNickname: message.recipientNickname, + channelKey: message.channelKey, + retryCount: message.retryCount + 1, + nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2)) + ) + self.retryQueue.append(updatedMessage) + } } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4e9f1e04..d8a07139 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2750,8 +2750,42 @@ extension ChatViewModel: BitchatDelegate { if channelMessages[channel] == nil { channelMessages[channel] = [] } - channelMessages[channel]?.append(finalMessage) - channelMessages[channel]?.sort { $0.timestamp < $1.timestamp } + + // Check if this is our own message being echoed back + if finalMessage.sender != nickname && finalMessage.sender != "system" { + // Skip empty or whitespace-only messages + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + channelMessages[channel]?.append(finalMessage) + channelMessages[channel]?.sort { $0.timestamp < $1.timestamp } + } + } else if finalMessage.sender != "system" { + // Our own message - check if we already have it (by ID and content) + let messageExists = channelMessages[channel]?.contains { existingMsg in + // Check by ID first + if existingMsg.id == finalMessage.id { + return true + } + // Check by content and sender with time window (within 1 second) + if existingMsg.content == finalMessage.content && + existingMsg.sender == finalMessage.sender { + let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) + return timeDiff < 1.0 + } + return false + } ?? false + if !messageExists { + // This is a message we sent from another device or it's missing locally + // Skip empty or whitespace-only messages + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + channelMessages[channel]?.append(finalMessage) + channelMessages[channel]?.sort { $0.timestamp < $1.timestamp } + } + } + } else { + // System message - always add + channelMessages[channel]?.append(finalMessage) + channelMessages[channel]?.sort { $0.timestamp < $1.timestamp } + } // Save message if channel has retention enabled if retentionEnabledChannels.contains(channel) { @@ -2767,8 +2801,8 @@ extension ChatViewModel: BitchatDelegate { } else { } - // Update unread count if not currently viewing this channel - if currentChannel != channel { + // Update unread count if not currently viewing this channel and it's not our own message + if currentChannel != channel && finalMessage.sender != nickname { unreadChannelMessages[channel] = (unreadChannelMessages[channel] ?? 0) + 1 } } else { @@ -2782,9 +2816,10 @@ extension ChatViewModel: BitchatDelegate { (message.content.contains("🫂") || message.content.contains("🐟") || message.content.contains("took a screenshot")) + let finalMessage: BitchatMessage if isActionMessage { // Convert to system message - let systemMessage = BitchatMessage( + finalMessage = BitchatMessage( sender: "system", content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper timestamp: message.timestamp, @@ -2796,12 +2831,46 @@ extension ChatViewModel: BitchatDelegate { mentions: message.mentions, channel: message.channel ) - messages.append(systemMessage) } else { - messages.append(message) + finalMessage = message + } + + // Check if this is our own message being echoed back + if finalMessage.sender != nickname && finalMessage.sender != "system" { + // Skip empty or whitespace-only messages + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(finalMessage) + // Sort messages by timestamp to ensure proper ordering + messages.sort { $0.timestamp < $1.timestamp } + } + } else if finalMessage.sender != "system" { + // Our own message - check if we already have it (by ID and content) + let messageExists = messages.contains { existingMsg in + // Check by ID first + if existingMsg.id == finalMessage.id { + return true + } + // Check by content and sender with time window (within 1 second) + if existingMsg.content == finalMessage.content && + existingMsg.sender == finalMessage.sender { + let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) + return timeDiff < 1.0 + } + return false + } + if !messageExists { + // This is a message we sent from another device or it's missing locally + // Skip empty or whitespace-only messages + if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + messages.append(finalMessage) + messages.sort { $0.timestamp < $1.timestamp } + } + } + } else { + // System message - always add + messages.append(finalMessage) + messages.sort { $0.timestamp < $1.timestamp } } - // Sort messages by timestamp to ensure proper ordering - messages.sort { $0.timestamp < $1.timestamp } } // Check if we're mentioned