diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 6ccea7e3..f9e692af 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -178,7 +178,7 @@ protocol BitchatDelegate: AnyObject { // Bluetooth state updates for user notifications func didUpdateBluetoothState(_ state: CBManagerState) - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) } // Provide default implementation to make it effectively optional @@ -195,7 +195,7 @@ extension BitchatDelegate { // Default empty implementation } - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { // Default empty implementation } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 141d2fbe..11717fbe 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -67,6 +67,7 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() + private var selfBroadcastMessageIDs: [String: (id: String, timestamp: Date)] = [:] private lazy var mediaDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" @@ -358,6 +359,9 @@ final class BLEService: NSObject { setNickname(currentNickname) messageDeduplicator.reset() + messageQueue.async(flags: .barrier) { [weak self] in + self?.selfBroadcastMessageIDs.removeAll() + } requestPeerDataPublish() startServices() } @@ -384,11 +388,13 @@ final class BLEService: NSObject { // Public broadcast // Create packet with explicit fields so we can sign it + let sendDate = timestamp ?? Date() + let sendTimestampMs = UInt64(sendDate.timeIntervalSince1970 * 1000) let basePacket = BitchatPacket( type: MessageType.message.rawValue, senderID: Data(hexString: myPeerID.id) ?? Data(), recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + timestamp: sendTimestampMs, payload: Data(content.utf8), signature: nil, ttl: messageTTL @@ -401,6 +407,9 @@ final class BLEService: NSObject { let senderHex = signedPacket.senderID.hexEncodedString() let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" messageDeduplicator.markProcessed(dedupID) + if let messageID { + selfBroadcastMessageIDs[dedupID] = (id: messageID, timestamp: sendDate) + } // Call synchronously since we're already on background queue broadcastPacket(signedPacket) // Track our own broadcast for sync @@ -642,6 +651,10 @@ final class BLEService: NSObject { // Delegate to the full API with default routing sendMessage(content, mentions: mentions, to: nil, messageID: nil, timestamp: nil) } + + func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + sendMessage(content, mentions: mentions, to: nil, messageID: messageID, timestamp: timestamp) + } func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { sendPrivateMessage(content, to: peerID, messageID: messageID) @@ -3417,8 +3430,18 @@ extension BLEService { SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: .session) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + var resolvedSelfMessageID: String? = nil + if peerID == myPeerID { + let senderHex = packet.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" + resolvedSelfMessageID = selfBroadcastMessageIDs.removeValue(forKey: dedupID)?.id + } notifyUI { [weak self] in - self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) + self?.delegate?.didReceivePublicMessage(from: peerID, + nickname: senderNickname, + content: content, + timestamp: ts, + messageID: resolvedSelfMessageID) } } @@ -3772,6 +3795,13 @@ extension BLEService { self.pendingDirectedRelays = cleaned } } + + messageQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + guard !self.selfBroadcastMessageIDs.isEmpty else { return } + let cutoff = now.addingTimeInterval(-TransportConfig.messageDedupMaxAgeSeconds) + self.selfBroadcastMessageIDs = self.selfBroadcastMessageIDs.filter { cutoff <= $0.value.timestamp } + } } private func updateScanningDutyCycle(connectedCount: Int) { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 18f714e3..f357e4f3 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -45,6 +45,7 @@ protocol Transport: AnyObject { // Messaging func sendMessage(_ content: String, mentions: [String]) + func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) @@ -65,6 +66,10 @@ extension Transport { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} + + func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + sendMessage(content, mentions: mentions) + } } protocol TransportPeerEventsDelegate: AnyObject { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index f7378938..a2046762 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1529,15 +1529,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // UI updates automatically via @Published var messages - updateChannelActivityTimeThenSend(content: content, trimmed: trimmed, mentions: mentions, geoContext: geoContext) + 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?) { + 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) + 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 { @@ -3335,7 +3345,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // In public chat - send to active public channel switch activeChannel { case .mesh: - meshService.sendMessage(screenshotMessage, mentions: []) + meshService.sendMessage(screenshotMessage, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date()) case .location(let ch): Task { @MainActor in do { @@ -5050,12 +5063,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { Task { @MainActor in let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) let publicMentions = parseMentions(from: normalized) let msg = BitchatMessage( - id: UUID().uuidString, + id: messageID, sender: nickname, content: normalized, timestamp: timestamp, @@ -5522,7 +5535,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { return } // Default: send over mesh - meshService.sendMessage(content, mentions: []) + meshService.sendMessage(content, + mentions: [], + messageID: UUID().uuidString, + timestamp: Date()) } // MARK: - Simplified Nostr Integration (Inlined from MessageRouter) @@ -6308,8 +6324,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Persist mesh messages to mesh timeline always if !isGeo && finalMessage.sender != "system" { - meshTimeline.append(finalMessage) - trimMeshTimelineIfNeeded() + if !meshTimeline.contains(where: { $0.id == finalMessage.id }) { + meshTimeline.append(finalMessage) + trimMeshTimelineIfNeeded() + } } // Persist geochat messages to per-geohash timeline diff --git a/bitchatTests/BLEServiceTests.swift b/bitchatTests/BLEServiceTests.swift index 0facccb7..af6eefdd 100644 --- a/bitchatTests/BLEServiceTests.swift +++ b/bitchatTests/BLEServiceTests.swift @@ -298,5 +298,5 @@ private final class MockBitchatDelegate: BitchatDelegate { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} - func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} } diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 577681a2..1632f9ad 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -209,7 +209,7 @@ extension FragmentationTests { func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {} func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {} func didUpdateBluetoothState(_ state: CBManagerState) {} - func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) { + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { publicMessages.append((peerID, nickname, content)) } func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}