mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:25:19 +00:00
Refactor/robustness (#446)
* Refactor BitChat for improved robustness and performance Major refactoring to simplify architecture and fix critical issues: Architecture Improvements: - Replace complex BluetoothMeshService with simplified SimplifiedBluetoothService - Consolidate message routing and peer management into unified services - Remove redundant caching layers and optimize performance Bug Fixes: - Fix critical BLE peer mapping corruption in mesh networks - Fix encrypted message routing failures in multi-peer scenarios - Fix app freezes and Main Thread Checker warnings - Fix BLE message delivery in dual-role connections - Fix favorite toggle UI not updating instantly - Fix Nostr offline messaging with 24-hour message filtering Features: - Add command processor for chat commands - Add autocomplete service for mentions and commands - Improve private chat management with dedicated service - Add unified peer service for consistent state management Performance: - Optimize BLE reconnection speed - Reduce excessive logging throughout codebase - Improve message deduplication efficiency - Optimize UI updates and state management * Improvements refactor robust (#441) * remove unused code * remove more * TLV for announcement * restore * restore * restore? * messages tlv too (#442) * Fix Nostr notification and read receipt issues, add TLV encoding, cleanup unused code ## Notification & Read Receipt Fixes - Fixed toolbar notification icon appearing incorrectly on app restart for already-read messages - Fixed read receipts being incorrectly deleted on startup when privateChats was empty - Fixed messages not being marked as read when opening chat for first time - Fixed senderPeerID not being updated during message consolidation - Added startup phase logic to block old messages (>30s) while allowing recent ones - Fixed unread status checking across all storage locations (ephemeral, stable Noise keys, temporary Nostr IDs) ## TLV Encoding Implementation - Implemented Type-Length-Value encoding for private message payloads - Added PrivateMessagePacket struct with TLV encode/decode methods - Enhanced message structure for better extensibility and robustness ## Code Cleanup - Removed unused PeerStateManager class and related dependencies - Removed dead protocol types (DeliveryAck, ProtocolAck/Nack, NoiseIdentityAnnouncement) - Cleaned up BitchatDelegate by removing unused methods - Removed excessive debug logging throughout ChatViewModel - Added test-only ProtocolNack helper for integration tests ## Technical Details - Messages stored under three ID types: ephemeral peer IDs, stable Noise key hexes, temporary Nostr IDs - Fixed cleanupOldReadReceipts() to skip when privateChats is empty or during startup - Updated message consolidation to properly update senderPeerID - Restored NIP-17 timestamp randomization (±15 minutes) for privacy --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// AutocompleteService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles autocomplete suggestions for mentions and commands
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Manages autocomplete functionality for chat
|
||||
class AutocompleteService {
|
||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||
|
||||
private let commands = [
|
||||
"/msg", "/who", "/clear", "/help",
|
||||
"/hug", "/slap", "/fav", "/unfav",
|
||||
"/block", "/unblock"
|
||||
]
|
||||
|
||||
/// Get autocomplete suggestions for current text
|
||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||
let textToPosition = String(text.prefix(cursorPosition))
|
||||
|
||||
// Check for mention autocomplete
|
||||
if let (mentionSuggestions, mentionRange) = getMentionSuggestions(textToPosition, peers: peers) {
|
||||
return (mentionSuggestions, mentionRange)
|
||||
}
|
||||
|
||||
// Don't handle command autocomplete here - ContentView handles it with better UI
|
||||
// if let (commandSuggestions, commandRange) = getCommandSuggestions(textToPosition) {
|
||||
// return (commandSuggestions, commandRange)
|
||||
// }
|
||||
|
||||
return ([], nil)
|
||||
}
|
||||
|
||||
/// Apply selected suggestion to text
|
||||
func applySuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
|
||||
guard let textRange = Range(range, in: text) else { return text }
|
||||
|
||||
var replacement = suggestion
|
||||
|
||||
// Add space after command if it takes arguments
|
||||
if suggestion.hasPrefix("/") && needsArgument(command: suggestion) {
|
||||
replacement += " "
|
||||
}
|
||||
|
||||
return text.replacingCharacters(in: textRange, with: replacement)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func getMentionSuggestions(_ text: String, peers: [String]) -> ([String], NSRange)? {
|
||||
guard let regex = mentionRegex else { return nil }
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
guard let match = matches.last else { return nil }
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let suggestions = peers
|
||||
.filter { $0.lowercased().hasPrefix(prefix) }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
.map { "@\($0)" }
|
||||
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
||||
guard let regex = commandRegex else { return nil }
|
||||
|
||||
let nsText = text as NSString
|
||||
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||
|
||||
guard let match = matches.last else { return nil }
|
||||
|
||||
let fullRange = match.range(at: 0)
|
||||
let captureRange = match.range(at: 1)
|
||||
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||
|
||||
let suggestions = commands
|
||||
.filter { $0.hasPrefix("/\(prefix)") }
|
||||
.sorted()
|
||||
.prefix(5)
|
||||
|
||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||
}
|
||||
|
||||
private func needsArgument(command: String) -> Bool {
|
||||
switch command {
|
||||
case "/who", "/clear", "/help":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
//
|
||||
// CommandProcessor.swift
|
||||
// bitchat
|
||||
//
|
||||
// Handles command parsing and execution for BitChat
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Result of command processing
|
||||
enum CommandResult {
|
||||
case success(message: String?)
|
||||
case error(message: String)
|
||||
case handled // Command handled, no message needed
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@MainActor
|
||||
class CommandProcessor {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
weak var meshService: SimplifiedBluetoothService?
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: SimplifiedBluetoothService? = nil) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
/// Process a command string
|
||||
@MainActor
|
||||
func process(_ command: String) -> CommandResult {
|
||||
let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
return handleMessage(args)
|
||||
case "/w", "/who":
|
||||
return handleWho()
|
||||
case "/clear":
|
||||
return handleClear()
|
||||
case "/hug":
|
||||
return handleEmote(args, action: "hugs", emoji: "🫂")
|
||||
case "/slap":
|
||||
return handleEmote(args, action: "slaps", emoji: "🐟", suffix: " around a bit with a large trout")
|
||||
case "/block":
|
||||
return handleBlock(args)
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/fav":
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
return handleFavorite(args, add: false)
|
||||
case "/help", "/h":
|
||||
return handleHelp()
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else {
|
||||
return .error(message: "usage: /msg @nickname [message]")
|
||||
}
|
||||
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
}
|
||||
|
||||
chatViewModel?.startPrivateChat(with: peerID)
|
||||
|
||||
if parts.count > 1 {
|
||||
let message = String(parts[1])
|
||||
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
}
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: "no one else is online right now")
|
||||
}
|
||||
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: "online: \(onlineList)")
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.messages.removeAll()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func handleEmote(_ args: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(action) <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let myNickname = chatViewModel?.nickname else {
|
||||
return .error(message: "cannot \(action) \(nickname): not found")
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.getPeerNicknames()[targetPeerID] {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString)
|
||||
}
|
||||
} else {
|
||||
// In public chat
|
||||
meshService?.sendMessage(emoteContent)
|
||||
}
|
||||
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func handleBlock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users
|
||||
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else {
|
||||
return .success(message: "no blocked peers")
|
||||
}
|
||||
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
if let fingerprint = meshService?.getPeerFingerprint(peerID),
|
||||
blockedUsers.contains(fingerprint) {
|
||||
blockedNicknames.append(nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)"
|
||||
: blockedNicknames.sorted().joined(separator: ", ")
|
||||
return .success(message: "blocked peers: \(list)")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
|
||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
}
|
||||
|
||||
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
|
||||
// Block the user
|
||||
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||
identity.isBlocked = true
|
||||
identity.isFavorite = false
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||
} else {
|
||||
let blockedIdentity = SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: nickname,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: true,
|
||||
notes: nil
|
||||
)
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
||||
}
|
||||
|
||||
// The peerStateManager and SecureIdentityStateManager handle the blocking state
|
||||
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getPeerFingerprint(peerID) else {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
|
||||
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
|
||||
// The SecureIdentityStateManager handles the unblocking state
|
||||
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelp() -> CommandResult {
|
||||
let helpText = """
|
||||
commands:
|
||||
/msg @name - start private chat
|
||||
/who - list who's online
|
||||
/clear - clear messages
|
||||
/hug @name - send a hug
|
||||
/slap @name - slap with a trout
|
||||
/fav @name - add to favorites
|
||||
/unfav @name - remove from favorites
|
||||
/block @name - block
|
||||
/unblock @name - unblock
|
||||
"""
|
||||
return .success(message: helpText)
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//
|
||||
// DeliveryTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class DeliveryTracker {
|
||||
static let shared = DeliveryTracker()
|
||||
|
||||
// Track pending deliveries
|
||||
private var pendingDeliveries: [String: PendingDelivery] = [:]
|
||||
private let pendingLock = NSLock()
|
||||
|
||||
// Track received ACKs to prevent duplicates
|
||||
private var receivedAckIDs = Set<String>()
|
||||
private var sentAckIDs = Set<String>()
|
||||
|
||||
// Timeout configuration
|
||||
private let privateMessageTimeout: TimeInterval = 120 // 2 minutes
|
||||
private let roomMessageTimeout: TimeInterval = 180 // 3 minutes
|
||||
private let favoriteTimeout: TimeInterval = 600 // 10 minutes for favorites
|
||||
|
||||
// Retry configuration
|
||||
private let maxRetries = 3
|
||||
private let retryDelay: TimeInterval = 5 // Base retry delay
|
||||
|
||||
// Publishers for UI updates
|
||||
let deliveryStatusUpdated = PassthroughSubject<(messageID: String, status: DeliveryStatus), Never>()
|
||||
|
||||
// Cleanup timer
|
||||
private var cleanupTimer: Timer?
|
||||
|
||||
struct PendingDelivery {
|
||||
let messageID: String
|
||||
let sentAt: Date
|
||||
let recipientID: String
|
||||
let recipientNickname: String
|
||||
let retryCount: Int
|
||||
let isFavorite: Bool
|
||||
var timeoutTimer: Timer?
|
||||
|
||||
var isTimedOut: Bool {
|
||||
let timeout: TimeInterval = isFavorite ? 300 : 30
|
||||
return Date().timeIntervalSince(sentAt) > timeout
|
||||
}
|
||||
|
||||
var shouldRetry: Bool {
|
||||
return retryCount < 3 && isFavorite
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
startCleanupTimer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cleanupTimer?.invalidate()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func trackMessage(_ message: BitchatMessage, recipientID: String, recipientNickname: String, isFavorite: Bool = false, expectedRecipients: Int = 1) {
|
||||
// Only track private messages
|
||||
guard message.isPrivate else { return }
|
||||
|
||||
SecureLogger.log("Tracking message \(message.id) - private: \(message.isPrivate), recipient: \(recipientNickname)", category: SecureLogger.session, level: .info)
|
||||
|
||||
|
||||
let delivery = PendingDelivery(
|
||||
messageID: message.id,
|
||||
sentAt: Date(),
|
||||
recipientID: recipientID,
|
||||
recipientNickname: recipientNickname,
|
||||
retryCount: 0,
|
||||
isFavorite: isFavorite,
|
||||
timeoutTimer: nil
|
||||
)
|
||||
|
||||
// Store the delivery with lock
|
||||
pendingLock.lock()
|
||||
pendingDeliveries[message.id] = delivery
|
||||
pendingLock.unlock()
|
||||
|
||||
// Update status to sent (only if not already delivered)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.pendingLock.lock()
|
||||
let stillPending = self.pendingDeliveries[message.id] != nil
|
||||
self.pendingLock.unlock()
|
||||
|
||||
// Only update to sent if still pending (not already delivered)
|
||||
if stillPending {
|
||||
SecureLogger.log("Updating message \(message.id) to sent status (still pending)", category: SecureLogger.session, level: .debug)
|
||||
self.updateDeliveryStatus(message.id, status: .sent)
|
||||
} else {
|
||||
SecureLogger.log("Skipping sent status update for \(message.id) - already delivered", category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule timeout (outside of lock)
|
||||
scheduleTimeout(for: message.id)
|
||||
}
|
||||
|
||||
func processDeliveryAck(_ ack: DeliveryAck) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
SecureLogger.log("Processing delivery ACK for message \(ack.originalMessageID) from \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
|
||||
|
||||
// Prevent duplicate ACK processing
|
||||
guard !receivedAckIDs.contains(ack.ackID) else {
|
||||
SecureLogger.log("Duplicate ACK \(ack.ackID) - ignoring", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
receivedAckIDs.insert(ack.ackID)
|
||||
|
||||
// Find the pending delivery
|
||||
guard let delivery = pendingDeliveries[ack.originalMessageID] else {
|
||||
// Message might have already been delivered or timed out
|
||||
SecureLogger.log("No pending delivery found for message \(ack.originalMessageID)", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel timeout timer
|
||||
delivery.timeoutTimer?.invalidate()
|
||||
|
||||
// Direct message - mark as delivered
|
||||
SecureLogger.log("Marking private message \(ack.originalMessageID) as delivered to \(ack.recipientNickname)", category: SecureLogger.session, level: .info)
|
||||
updateDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: Date()))
|
||||
pendingDeliveries.removeValue(forKey: ack.originalMessageID)
|
||||
}
|
||||
|
||||
func generateAck(for message: BitchatMessage, myPeerID: String, myNickname: String, hopCount: UInt8) -> DeliveryAck? {
|
||||
// Don't ACK our own messages
|
||||
guard message.senderPeerID != myPeerID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only ACK private messages
|
||||
guard message.isPrivate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't ACK if we've already sent an ACK for this message
|
||||
guard !sentAckIDs.contains(message.id) else {
|
||||
return nil
|
||||
}
|
||||
sentAckIDs.insert(message.id)
|
||||
|
||||
|
||||
return DeliveryAck(
|
||||
originalMessageID: message.id,
|
||||
recipientID: myPeerID,
|
||||
recipientNickname: myNickname,
|
||||
hopCount: hopCount
|
||||
)
|
||||
}
|
||||
|
||||
func clearDeliveryStatus(for messageID: String) {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
if let delivery = pendingDeliveries[messageID] {
|
||||
delivery.timeoutTimer?.invalidate()
|
||||
}
|
||||
pendingDeliveries.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func updateDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||
SecureLogger.log("Updating delivery status for message \(messageID): \(status)", category: SecureLogger.session, level: .debug)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.deliveryStatusUpdated.send((messageID: messageID, status: status))
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTimeout(for messageID: String) {
|
||||
// Get delivery info with lock
|
||||
pendingLock.lock()
|
||||
guard let delivery = pendingDeliveries[messageID] else {
|
||||
pendingLock.unlock()
|
||||
return
|
||||
}
|
||||
let isFavorite = delivery.isFavorite
|
||||
pendingLock.unlock()
|
||||
|
||||
let timeout = isFavorite ? favoriteTimeout : privateMessageTimeout
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in
|
||||
self?.handleTimeout(messageID: messageID)
|
||||
}
|
||||
|
||||
pendingLock.lock()
|
||||
if var updatedDelivery = pendingDeliveries[messageID] {
|
||||
updatedDelivery.timeoutTimer = timer
|
||||
pendingDeliveries[messageID] = updatedDelivery
|
||||
}
|
||||
pendingLock.unlock()
|
||||
}
|
||||
|
||||
private func handleTimeout(messageID: String) {
|
||||
pendingLock.lock()
|
||||
guard let delivery = pendingDeliveries[messageID] else {
|
||||
pendingLock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
let shouldRetry = delivery.shouldRetry
|
||||
|
||||
if shouldRetry {
|
||||
pendingLock.unlock()
|
||||
// Retry for favorites (outside of lock)
|
||||
retryDelivery(messageID: messageID)
|
||||
} else {
|
||||
// Mark as failed
|
||||
let reason = "Message not delivered"
|
||||
pendingDeliveries.removeValue(forKey: messageID)
|
||||
pendingLock.unlock()
|
||||
updateDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||
}
|
||||
}
|
||||
|
||||
private func retryDelivery(messageID: String) {
|
||||
pendingLock.lock()
|
||||
guard let delivery = pendingDeliveries[messageID] else {
|
||||
pendingLock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Increment retry count
|
||||
let newDelivery = PendingDelivery(
|
||||
messageID: delivery.messageID,
|
||||
sentAt: delivery.sentAt,
|
||||
recipientID: delivery.recipientID,
|
||||
recipientNickname: delivery.recipientNickname,
|
||||
retryCount: delivery.retryCount + 1,
|
||||
isFavorite: delivery.isFavorite,
|
||||
timeoutTimer: nil
|
||||
)
|
||||
|
||||
pendingDeliveries[messageID] = newDelivery
|
||||
let retryCount = delivery.retryCount
|
||||
pendingLock.unlock()
|
||||
|
||||
// Exponential backoff for retry
|
||||
let delay = retryDelay * pow(2, Double(retryCount))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
// Trigger resend through delegate or notification
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.Name("bitchat.retryMessage"),
|
||||
object: nil,
|
||||
userInfo: ["messageID": messageID]
|
||||
)
|
||||
|
||||
// Schedule new timeout
|
||||
self?.scheduleTimeout(for: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func startCleanupTimer() {
|
||||
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
|
||||
self?.cleanupOldDeliveries()
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupOldDeliveries() {
|
||||
pendingLock.lock()
|
||||
defer { pendingLock.unlock() }
|
||||
|
||||
let now = Date()
|
||||
let maxAge: TimeInterval = 3600 // 1 hour
|
||||
|
||||
// Clean up old pending deliveries
|
||||
pendingDeliveries = pendingDeliveries.filter { (_, delivery) in
|
||||
now.timeIntervalSince(delivery.sentAt) < maxAge
|
||||
}
|
||||
|
||||
// Clean up old ACK IDs (keep last 1000)
|
||||
if receivedAckIDs.count > 1000 {
|
||||
receivedAckIDs.removeAll()
|
||||
}
|
||||
if sentAckIDs.count > 1000 {
|
||||
sentAckIDs.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
//
|
||||
// MessageRetryService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import CryptoKit
|
||||
|
||||
struct RetryableMessage {
|
||||
let id: String
|
||||
let originalMessageID: String?
|
||||
let originalTimestamp: Date?
|
||||
let content: String
|
||||
let mentions: [String]?
|
||||
let isPrivate: Bool
|
||||
let recipientPeerID: String?
|
||||
let recipientNickname: String?
|
||||
let retryCount: Int
|
||||
let maxRetries: Int = 3
|
||||
let nextRetryTime: Date
|
||||
}
|
||||
|
||||
class MessageRetryService {
|
||||
static let shared = MessageRetryService()
|
||||
|
||||
private var retryQueue: [RetryableMessage] = []
|
||||
private var retryTimer: Timer?
|
||||
private let retryInterval: TimeInterval = 2.0 // Retry every 2 seconds for faster sync
|
||||
private let maxQueueSize = 50
|
||||
|
||||
weak var meshService: BluetoothMeshService?
|
||||
|
||||
private init() {
|
||||
startRetryTimer()
|
||||
}
|
||||
|
||||
deinit {
|
||||
retryTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func startRetryTimer() {
|
||||
retryTimer = Timer.scheduledTimer(withTimeInterval: retryInterval, repeats: true) { [weak self] _ in
|
||||
self?.processRetryQueue()
|
||||
}
|
||||
}
|
||||
|
||||
func addMessageForRetry(
|
||||
content: String,
|
||||
mentions: [String]? = nil,
|
||||
isPrivate: Bool = false,
|
||||
recipientPeerID: String? = nil,
|
||||
recipientNickname: String? = 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,
|
||||
isPrivate: isPrivate,
|
||||
recipientPeerID: recipientPeerID,
|
||||
recipientNickname: recipientNickname,
|
||||
retryCount: 0,
|
||||
nextRetryTime: Date().addingTimeInterval(retryInterval)
|
||||
)
|
||||
|
||||
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 meshService != nil else { return }
|
||||
|
||||
let now = Date()
|
||||
var messagesToRetry: [RetryableMessage] = []
|
||||
var updatedQueue: [RetryableMessage] = []
|
||||
|
||||
for message in retryQueue {
|
||||
if message.nextRetryTime <= now {
|
||||
messagesToRetry.append(message)
|
||||
} else {
|
||||
updatedQueue.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
retryQueue = updatedQueue
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Add delay between messages to ensure proper ordering
|
||||
let delay = Double(index) * 0.05 // 50ms between messages
|
||||
|
||||
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,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.recipientPeerID,
|
||||
recipientNickname: message.recipientNickname,
|
||||
retryCount: message.retryCount + 1,
|
||||
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
|
||||
)
|
||||
self.retryQueue.append(updatedMessage)
|
||||
}
|
||||
} else {
|
||||
// Regular message
|
||||
if !connectedPeers.isEmpty {
|
||||
meshService.sendMessage(
|
||||
message.content,
|
||||
mentions: message.mentions ?? [],
|
||||
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,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientPeerID: message.recipientPeerID,
|
||||
recipientNickname: message.recipientNickname,
|
||||
retryCount: message.retryCount + 1,
|
||||
nextRetryTime: Date().addingTimeInterval(self.retryInterval * Double(message.retryCount + 2))
|
||||
)
|
||||
self.retryQueue.append(updatedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearRetryQueue() {
|
||||
retryQueue.removeAll()
|
||||
}
|
||||
|
||||
func getRetryQueueCount() -> Int {
|
||||
return retryQueue.count
|
||||
}
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Routes messages through the appropriate transport (Bluetooth mesh or Nostr)
|
||||
@MainActor
|
||||
class MessageRouter: ObservableObject {
|
||||
|
||||
enum Transport {
|
||||
case bluetoothMesh
|
||||
case nostr
|
||||
}
|
||||
|
||||
enum DeliveryStatus {
|
||||
case pending
|
||||
case sent
|
||||
case delivered
|
||||
case failed(Error)
|
||||
}
|
||||
|
||||
struct RoutedMessage {
|
||||
let id: String
|
||||
let content: String
|
||||
let recipientNoisePublicKey: Data
|
||||
let transport: Transport
|
||||
let timestamp: Date
|
||||
var status: DeliveryStatus
|
||||
}
|
||||
|
||||
@Published private(set) var pendingMessages: [String: RoutedMessage] = [:]
|
||||
|
||||
private let meshService: BluetoothMeshService
|
||||
private let nostrRelay: NostrRelayManager
|
||||
private let favoritesService: FavoritesPersistenceService
|
||||
private let processedMessagesService = ProcessedMessagesService.shared
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let messageDeduplication = LRUCache<String, Date>(maxSize: 1000)
|
||||
|
||||
init(
|
||||
meshService: BluetoothMeshService,
|
||||
nostrRelay: NostrRelayManager
|
||||
) {
|
||||
self.meshService = meshService
|
||||
self.nostrRelay = nostrRelay
|
||||
self.favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
setupBindings()
|
||||
}
|
||||
|
||||
/// Send a message to a peer, automatically selecting the best transport
|
||||
func sendMessage(
|
||||
_ content: String,
|
||||
to recipientNoisePublicKey: Data,
|
||||
preferredTransport: Transport? = nil,
|
||||
messageId: String? = nil
|
||||
) async throws {
|
||||
|
||||
let finalMessageId = messageId ?? UUID().uuidString
|
||||
|
||||
// Check if peer is available on mesh (actually connected, not just known)
|
||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
||||
let peerAvailableOnMesh = meshService.isPeerConnected(recipientHexID)
|
||||
|
||||
// Check if this is a mutual favorite
|
||||
let isMutualFavorite = favoritesService.isMutualFavorite(recipientNoisePublicKey)
|
||||
|
||||
// Determine transport
|
||||
let transport: Transport
|
||||
if let preferred = preferredTransport {
|
||||
transport = preferred
|
||||
} else if peerAvailableOnMesh {
|
||||
// Always prefer mesh when available
|
||||
transport = .bluetoothMesh
|
||||
} else if isMutualFavorite {
|
||||
// Use Nostr for mutual favorites when not on mesh
|
||||
transport = .nostr
|
||||
} else {
|
||||
throw MessageRouterError.peerNotReachable
|
||||
}
|
||||
|
||||
// Create routed message
|
||||
let routedMessage = RoutedMessage(
|
||||
id: finalMessageId,
|
||||
content: content,
|
||||
recipientNoisePublicKey: recipientNoisePublicKey,
|
||||
transport: transport,
|
||||
timestamp: Date(),
|
||||
status: .pending
|
||||
)
|
||||
|
||||
pendingMessages[finalMessageId] = routedMessage
|
||||
|
||||
// Route based on transport
|
||||
switch transport {
|
||||
case .bluetoothMesh:
|
||||
try await sendViaMesh(routedMessage)
|
||||
|
||||
case .nostr:
|
||||
try await sendViaNostr(routedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a favorite/unfavorite notification
|
||||
func sendFavoriteNotification(
|
||||
to recipientNoisePublicKey: Data,
|
||||
isFavorite: Bool
|
||||
) async throws {
|
||||
|
||||
// messageType is used for logging below
|
||||
// let messageType: MessageType = isFavorite ? .favorited : .unfavorited
|
||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
||||
let action = isFavorite ? "favorite" : "unfavorite"
|
||||
|
||||
|
||||
// Try mesh first
|
||||
if meshService.getPeerNicknames()[recipientHexID] != nil {
|
||||
|
||||
// Send via mesh as a system message
|
||||
meshService.sendFavoriteNotification(to: recipientHexID, isFavorite: isFavorite)
|
||||
|
||||
} else if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey),
|
||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey {
|
||||
|
||||
|
||||
// Send via Nostr as a special message
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
throw MessageRouterError.noNostrIdentity
|
||||
}
|
||||
|
||||
// Include our npub in the content
|
||||
let content = isFavorite ? "FAVORITED:\(senderIdentity.npub)" : "UNFAVORITED:\(senderIdentity.npub)"
|
||||
let event = try NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
)
|
||||
|
||||
nostrRelay.sendEvent(event)
|
||||
} else {
|
||||
SecureLogger.log("⚠️ Cannot send \(action) notification - peer not reachable via mesh or Nostr",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendViaMesh(_ message: RoutedMessage) async throws {
|
||||
// Send the message through mesh - using sendPrivateMessage for now
|
||||
let recipientHexID = message.recipientNoisePublicKey.hexEncodedString()
|
||||
if let recipientNickname = meshService.getPeerNicknames()[recipientHexID] {
|
||||
meshService.sendPrivateMessage(message.content, to: recipientHexID, recipientNickname: recipientNickname, messageID: message.id)
|
||||
}
|
||||
|
||||
// Update status
|
||||
pendingMessages[message.id]?.status = .sent
|
||||
}
|
||||
|
||||
private func sendViaNostr(_ message: RoutedMessage) async throws {
|
||||
// Get recipient's Nostr public key
|
||||
let favoriteStatus = favoritesService.getFavoriteStatus(for: message.recipientNoisePublicKey)
|
||||
|
||||
// Looking up Nostr key for recipient
|
||||
|
||||
if favoriteStatus != nil {
|
||||
// Found favorite relationship
|
||||
} else {
|
||||
SecureLogger.log("❌ No favorite relationship found",
|
||||
category: SecureLogger.session, level: .error)
|
||||
}
|
||||
|
||||
guard let favoriteStatus = favoriteStatus,
|
||||
let recipientNostrPubkey = favoriteStatus.peerNostrPublicKey else {
|
||||
throw MessageRouterError.noNostrPublicKey
|
||||
}
|
||||
|
||||
// Get sender's Nostr identity
|
||||
guard let senderIdentity = try NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
throw MessageRouterError.noNostrIdentity
|
||||
}
|
||||
|
||||
// Create NIP-17 encrypted message with structured content
|
||||
let structuredContent = "MSG:\(message.id):\(message.content)"
|
||||
let event = try NostrProtocol.createPrivateMessage(
|
||||
content: structuredContent,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
)
|
||||
|
||||
// Created gift wrap event
|
||||
|
||||
// Send via relay
|
||||
nostrRelay.sendEvent(event)
|
||||
|
||||
// Update status
|
||||
pendingMessages[message.id]?.status = .sent
|
||||
}
|
||||
|
||||
private func setupBindings() {
|
||||
// Monitor Nostr messages
|
||||
setupNostrMessageHandling()
|
||||
|
||||
// Clean up old pending messages periodically
|
||||
Timer.publish(every: 60, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in
|
||||
self?.cleanupOldMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Listen for app becoming active to check for messages
|
||||
NotificationCenter.default.publisher(for: .appDidBecomeActive)
|
||||
.sink { [weak self] _ in
|
||||
self?.checkForNostrMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func setupNostrMessageHandling() {
|
||||
guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else {
|
||||
SecureLogger.log("⚠️ No Nostr identity available for initial setup", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Connect to relays if not already connected
|
||||
if !nostrRelay.isConnected {
|
||||
nostrRelay.connect()
|
||||
|
||||
// Wait for connections to establish before subscribing
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||||
self?.subscribeToNostrMessages()
|
||||
}
|
||||
} else {
|
||||
// Already connected, subscribe immediately
|
||||
subscribeToNostrMessages()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for Nostr messages when app becomes active
|
||||
func checkForNostrMessages() {
|
||||
// Checking for Nostr messages
|
||||
|
||||
guard (try? NostrIdentityBridge.getCurrentNostrIdentity()) != nil else {
|
||||
SecureLogger.log("⚠️ No Nostr identity available for message check", category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we're connected to relays first
|
||||
if !nostrRelay.isConnected {
|
||||
// Connecting to Nostr relays
|
||||
nostrRelay.connect()
|
||||
|
||||
// Wait a bit for connections to establish before subscribing
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.subscribeToNostrMessages()
|
||||
}
|
||||
} else {
|
||||
// Already connected, subscribe immediately
|
||||
subscribeToNostrMessages()
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribeToNostrMessages() {
|
||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
|
||||
// Subscribing to Nostr messages
|
||||
// Full pubkey recorded
|
||||
// Pubkey length verified
|
||||
|
||||
// Unsubscribe existing subscription to refresh
|
||||
nostrRelay.unsubscribe(id: "router-messages")
|
||||
|
||||
// Create a new subscription for recent messages
|
||||
let sinceDate = processedMessagesService.getSubscriptionSinceDate()
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: sinceDate
|
||||
)
|
||||
|
||||
// Subscribing to messages since date
|
||||
|
||||
// Subscribing to gift wraps
|
||||
|
||||
nostrRelay.subscribe(filter: filter, id: "router-messages") { [weak self] event in
|
||||
// Received Nostr event
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
// Check if we've already processed this event
|
||||
if processedMessagesService.isMessageProcessed(giftWrap.id) {
|
||||
// Skipping already processed event
|
||||
return
|
||||
}
|
||||
|
||||
// Attempting to decrypt gift wrap
|
||||
// Full event ID recorded
|
||||
// Event timestamp recorded
|
||||
// Event tags recorded
|
||||
|
||||
// Decrypt the message
|
||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("❌ No current Nostr identity available",
|
||||
category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this event is actually tagged for us
|
||||
let ourPubkey = currentIdentity.publicKeyHex
|
||||
let isTaggedForUs = giftWrap.tags.contains { tag in
|
||||
tag.count >= 2 && tag[0] == "p" && tag[1] == ourPubkey
|
||||
}
|
||||
|
||||
if !isTaggedForUs {
|
||||
SecureLogger.log("⚠️ Gift wrap not tagged for us! Our pubkey: \(ourPubkey.prefix(8))..., Event tags: \(giftWrap.tags)",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let (content, senderPubkey) = try NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
|
||||
// Mark this event as processed to avoid duplicates on app restart
|
||||
let eventTimestamp = Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at))
|
||||
processedMessagesService.markMessageAsProcessed(giftWrap.id, timestamp: eventTimestamp)
|
||||
|
||||
// Check for deduplication within current session
|
||||
let messageHash = "\(senderPubkey)-\(content)-\(giftWrap.created_at)"
|
||||
if messageDeduplication.get(messageHash) != nil {
|
||||
return // Already processed in this session
|
||||
}
|
||||
messageDeduplication.set(messageHash, value: Date())
|
||||
|
||||
// Handle special messages
|
||||
if content.hasPrefix("FAVORITED") || content.hasPrefix("UNFAVORITED") {
|
||||
let parts = content.split(separator: ":")
|
||||
let isFavorite = parts.first == "FAVORITED"
|
||||
let nostrNpub = parts.count > 1 ? String(parts[1]) : nil
|
||||
handleFavoriteNotification(from: senderPubkey, isFavorite: isFavorite, nostrNpub: nostrNpub)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle delivery acknowledgments
|
||||
if content.hasPrefix("DELIVERED:") {
|
||||
let parts = content.split(separator: ":")
|
||||
if parts.count > 1 {
|
||||
let messageId = String(parts[1])
|
||||
handleDeliveryAcknowledgment(messageId: messageId, from: senderPubkey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle read receipts
|
||||
if content.hasPrefix("READ:") {
|
||||
let parts = content.split(separator: ":", maxSplits: 1)
|
||||
if parts.count > 1 {
|
||||
let receiptDataString = String(parts[1])
|
||||
if let receiptData = Data(base64Encoded: receiptDataString),
|
||||
let receipt = ReadReceipt.fromBinaryData(receiptData) {
|
||||
handleReadReceipt(receipt, from: senderPubkey)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Find the sender's Noise public key
|
||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
||||
|
||||
// Parse structured message content
|
||||
var messageId = UUID().uuidString
|
||||
var messageContent = content
|
||||
|
||||
if content.hasPrefix("MSG:") {
|
||||
let parts = content.split(separator: ":", maxSplits: 2)
|
||||
if parts.count >= 3 {
|
||||
messageId = String(parts[1])
|
||||
messageContent = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Create a BitchatMessage and inject into the stream
|
||||
let chatMessage = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: favoritesService.getFavoriteStatus(for: senderNoiseKey)?.peerNickname ?? "Unknown",
|
||||
content: messageContent,
|
||||
timestamp: Date(timeIntervalSince1970: TimeInterval(giftWrap.created_at)),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: senderNoiseKey.hexEncodedString(),
|
||||
mentions: nil,
|
||||
deliveryStatus: .delivered(to: "nostr", at: Date())
|
||||
)
|
||||
|
||||
// Post notification for ChatViewModel to handle
|
||||
NotificationCenter.default.post(
|
||||
name: .nostrMessageReceived,
|
||||
object: nil,
|
||||
userInfo: ["message": chatMessage]
|
||||
)
|
||||
|
||||
// Send delivery acknowledgment back to sender
|
||||
sendDeliveryAcknowledgment(for: chatMessage.id, to: senderPubkey)
|
||||
|
||||
} catch {
|
||||
SecureLogger.log("❌ Failed to decrypt gift wrap: \(error)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFavoriteNotification(from nostrPubkey: String, isFavorite: Bool, nostrNpub: String? = nil) {
|
||||
// Find the sender's Noise public key
|
||||
guard let senderNoiseKey = findNoisePublicKey(for: nostrPubkey) else { return }
|
||||
|
||||
// Update favorites service - nostrPubkey is already the hex public key
|
||||
favoritesService.updatePeerFavoritedUs(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
favorited: isFavorite,
|
||||
peerNostrPublicKey: nostrPubkey
|
||||
)
|
||||
|
||||
// Post notification for UI update
|
||||
NotificationCenter.default.post(
|
||||
name: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"peerPublicKey": senderNoiseKey,
|
||||
"isFavorite": isFavorite
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func findNoisePublicKey(for nostrPubkey: String) -> Data? {
|
||||
// Search through favorites for matching Nostr pubkey
|
||||
for (noiseKey, relationship) in favoritesService.favorites {
|
||||
if relationship.peerNostrPublicKey == nostrPubkey {
|
||||
return noiseKey
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handleDeliveryAcknowledgment(messageId: String, from senderPubkey: String) {
|
||||
|
||||
// Find the sender's Noise public key
|
||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
||||
|
||||
// Post notification for ChatViewModel to update delivery status
|
||||
NotificationCenter.default.post(
|
||||
name: .messageDeliveryAcknowledged,
|
||||
object: nil,
|
||||
userInfo: [
|
||||
"messageId": messageId,
|
||||
"senderNoiseKey": senderNoiseKey
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
private func handleReadReceipt(_ receipt: ReadReceipt, from senderPubkey: String) {
|
||||
|
||||
// Find the sender's Noise public key
|
||||
guard let senderNoiseKey = findNoisePublicKey(for: senderPubkey) else { return }
|
||||
let senderHexID = senderNoiseKey.hexEncodedString()
|
||||
|
||||
// Update the receipt with the correct sender ID
|
||||
var updatedReceipt = receipt
|
||||
updatedReceipt.readerID = senderHexID
|
||||
|
||||
// Post notification for ChatViewModel to process
|
||||
NotificationCenter.default.post(
|
||||
name: .readReceiptReceived,
|
||||
object: nil,
|
||||
userInfo: ["receipt": updatedReceipt]
|
||||
)
|
||||
}
|
||||
|
||||
func sendReadReceipt(
|
||||
for originalMessageID: String,
|
||||
to recipientNoisePublicKey: Data,
|
||||
preferredTransport: Transport? = nil
|
||||
) async throws {
|
||||
|
||||
// Get nickname from delegate or use default
|
||||
let nickname = (meshService.delegate as? ChatViewModel)?.nickname ?? "Anonymous"
|
||||
|
||||
// Create read receipt
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: originalMessageID,
|
||||
readerID: meshService.myPeerID,
|
||||
readerNickname: nickname
|
||||
)
|
||||
|
||||
// Encode receipt
|
||||
let receiptData = receipt.toBinaryData()
|
||||
let content = "READ:\(receiptData.base64EncodedString())"
|
||||
|
||||
// Check if peer is connected via mesh (mesh takes precedence)
|
||||
let recipientHexID = recipientNoisePublicKey.hexEncodedString()
|
||||
|
||||
// First check if the peer is currently connected with the given ID
|
||||
var actualRecipientHexID = recipientHexID
|
||||
var actualRecipientNoiseKey = recipientNoisePublicKey
|
||||
|
||||
// Always check if they reconnected with a new ID, even if preferredTransport is specified
|
||||
if let favoriteStatus = favoritesService.getFavoriteStatus(for: recipientNoisePublicKey) {
|
||||
let peerNickname = favoriteStatus.peerNickname
|
||||
|
||||
// Search through all current peers to find one with the same nickname
|
||||
for (currentPeerID, currentNickname) in meshService.getPeerNicknames() {
|
||||
if currentNickname == peerNickname,
|
||||
currentPeerID != recipientHexID,
|
||||
let currentNoiseKey = Data(hexString: currentPeerID) {
|
||||
SecureLogger.log("🔄 Found updated peer ID for \(peerNickname): \(recipientHexID) -> \(currentPeerID)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
actualRecipientHexID = currentPeerID
|
||||
actualRecipientNoiseKey = currentNoiseKey
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found in connected peers, check all favorites for the current key
|
||||
if meshService.getPeerNicknames()[actualRecipientHexID] == nil {
|
||||
// Search through all favorites to find the current noise key for this nickname
|
||||
for (noiseKey, relationship) in favoritesService.favorites {
|
||||
if relationship.peerNickname == peerNickname && relationship.peerNostrPublicKey != nil {
|
||||
SecureLogger.log("🔄 Using current favorite key for \(peerNickname): \(recipientHexID) -> \(noiseKey.hexEncodedString())",
|
||||
category: SecureLogger.session, level: .info)
|
||||
actualRecipientHexID = noiseKey.hexEncodedString()
|
||||
actualRecipientNoiseKey = noiseKey
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let isConnectedOnMesh = meshService.isPeerConnected(actualRecipientHexID)
|
||||
|
||||
if isConnectedOnMesh && preferredTransport != .nostr {
|
||||
// Send via mesh
|
||||
SecureLogger.log("📡 Sending read receipt via mesh to \(actualRecipientHexID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
meshService.sendReadReceipt(receipt, to: actualRecipientHexID)
|
||||
} else {
|
||||
// Send via Nostr
|
||||
SecureLogger.log("🌐 Sending read receipt via Nostr to \(actualRecipientHexID)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Get recipient's Nostr public key using the actual current noise key
|
||||
let favoriteStatus = favoritesService.getFavoriteStatus(for: actualRecipientNoiseKey)
|
||||
guard let recipientNostrPubkey = favoriteStatus?.peerNostrPublicKey else {
|
||||
SecureLogger.log("❌ Cannot send read receipt - no Nostr key for recipient",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw MessageRouterError.noNostrKey
|
||||
}
|
||||
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("⚠️ No Nostr identity available for read receipt",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
throw MessageRouterError.noIdentity
|
||||
}
|
||||
|
||||
// Create read receipt message
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("❌ Failed to create read receipt",
|
||||
category: SecureLogger.session, level: .error)
|
||||
throw MessageRouterError.encryptionFailed
|
||||
}
|
||||
|
||||
// Send via relay
|
||||
nostrRelay.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendDeliveryAcknowledgment(for messageId: String, to recipientNostrPubkey: String) {
|
||||
SecureLogger.log("📤 Sending delivery acknowledgment for message \(messageId)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
|
||||
SecureLogger.log("⚠️ No Nostr identity available for acknowledgment",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
// Create acknowledgment message
|
||||
let content = "DELIVERED:\(messageId)"
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(
|
||||
content: content,
|
||||
recipientPubkey: recipientNostrPubkey,
|
||||
senderIdentity: senderIdentity
|
||||
) else {
|
||||
SecureLogger.log("❌ Failed to create delivery acknowledgment",
|
||||
category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
|
||||
// Send via relay
|
||||
nostrRelay.sendEvent(event)
|
||||
}
|
||||
|
||||
private func cleanupOldMessages() {
|
||||
let cutoff = Date().addingTimeInterval(-300) // 5 minutes
|
||||
pendingMessages = pendingMessages.filter { $0.value.timestamp > cutoff }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum MessageRouterError: LocalizedError {
|
||||
case peerNotReachable
|
||||
case noNostrPublicKey
|
||||
case noNostrIdentity
|
||||
case transportFailed
|
||||
case noNostrKey
|
||||
case noIdentity
|
||||
case encryptionFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .peerNotReachable:
|
||||
return "Peer is not reachable via mesh or Nostr"
|
||||
case .noNostrPublicKey:
|
||||
return "Peer's Nostr public key is unknown"
|
||||
case .noNostrIdentity:
|
||||
return "No Nostr identity available"
|
||||
case .transportFailed:
|
||||
return "Failed to send message"
|
||||
case .noNostrKey:
|
||||
return "No Nostr key available for recipient"
|
||||
case .noIdentity:
|
||||
return "No identity available"
|
||||
case .encryptionFailed:
|
||||
return "Failed to encrypt message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
static let nostrMessageReceived = Notification.Name("NostrMessageReceived")
|
||||
static let messageDeliveryAcknowledged = Notification.Name("MessageDeliveryAcknowledged")
|
||||
static let readReceiptReceived = Notification.Name("ReadReceiptReceived")
|
||||
static let appDidBecomeActive = Notification.Name("AppDidBecomeActive")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
///
|
||||
/// High-level encryption service that manages Noise Protocol sessions for secure
|
||||
/// peer-to-peer communication in BitChat. Acts as the bridge between the transport
|
||||
/// layer (BluetoothMeshService) and the cryptographic layer (NoiseProtocol).
|
||||
/// layer (SimplifiedBluetoothService) and the cryptographic layer (NoiseProtocol).
|
||||
///
|
||||
/// ## Overview
|
||||
/// This service provides a simplified API for establishing and managing encrypted
|
||||
@@ -60,7 +60,7 @@
|
||||
/// ```
|
||||
///
|
||||
/// ## Integration Points
|
||||
/// - **BluetoothMeshService**: Calls this service for all private messages
|
||||
/// - **SimplifiedBluetoothService**: Calls this service for all private messages
|
||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
||||
/// - **KeychainManager**: Secure storage for identity keys
|
||||
@@ -162,9 +162,26 @@ class NoiseEncryptionService {
|
||||
private let rekeyCheckInterval: TimeInterval = 60.0 // Check every minute
|
||||
|
||||
// Callbacks
|
||||
var onPeerAuthenticated: ((String, String) -> Void)? // peerID, fingerprint
|
||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||
serviceQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.append(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support - setting this will add to the handlers array
|
||||
var onPeerAuthenticated: ((String, String) -> Void)? {
|
||||
get { nil } // Always return nil for backward compatibility
|
||||
set {
|
||||
if let handler = newValue {
|
||||
addOnPeerAuthenticatedHandler(handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load or create static identity key (ONLY from keychain)
|
||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||
@@ -434,8 +451,12 @@ class NoiseEncryptionService {
|
||||
// Log security event
|
||||
SecureLogger.logSecurityEvent(.handshakeCompleted(peerID: peerID))
|
||||
|
||||
// Notify about authentication
|
||||
onPeerAuthenticated?(peerID, fingerprint)
|
||||
// Notify all handlers about authentication
|
||||
serviceQueue.async { [weak self] in
|
||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||
handler(peerID, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// PrivateChatManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages private chat sessions and messages
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Manages all private chat functionality
|
||||
class PrivateChatManager: ObservableObject {
|
||||
@Published var privateChats: [String: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: String? = nil
|
||||
@Published var unreadMessages: Set<String> = []
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
weak var meshService: SimplifiedBluetoothService?
|
||||
|
||||
init(meshService: SimplifiedBluetoothService? = nil) {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: String) {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getPeerFingerprint(peerID) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
// Mark messages as read
|
||||
markAsRead(from: peerID)
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current private chat
|
||||
func endChat() {
|
||||
selectedPeer = nil
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
/// Send a private message
|
||||
func sendMessage(_ content: String, to peerID: String) {
|
||||
guard let meshService = meshService,
|
||||
let peerNickname = meshService.getPeerNicknames()[peerID] else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageID = UUID().uuidString
|
||||
|
||||
// Create local message
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: meshService.myNickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: peerNickname,
|
||||
senderPeerID: meshService.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
// Add to chat
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
privateChats[peerID]?.append(message)
|
||||
|
||||
// Send via mesh service
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||
}
|
||||
|
||||
/// Handle incoming private message
|
||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||
guard let senderPeerID = message.senderPeerID else { return }
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[senderPeerID] == nil {
|
||||
privateChats[senderPeerID] = []
|
||||
}
|
||||
|
||||
// Add message
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Send notification
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
} else {
|
||||
// Send read receipt if viewing this chat
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark messages from a peer as read
|
||||
func markAsRead(from peerID: String) {
|
||||
unreadMessages.remove(peerID)
|
||||
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
if let messages = privateChats[peerID] {
|
||||
for message in messages {
|
||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
||||
func updateSelectedPeer(peers: [String: String]) {
|
||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
||||
|
||||
// Find peer with matching fingerprint
|
||||
for (peerID, _) in peers {
|
||||
if meshService?.getPeerFingerprint(peerID) == fingerprint {
|
||||
selectedPeer = peerID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chat messages for current context
|
||||
func getCurrentMessages() -> [BitchatMessage] {
|
||||
guard let peer = selectedPeer else { return [] }
|
||||
return privateChats[peer] ?? []
|
||||
}
|
||||
|
||||
/// Clear a private chat
|
||||
func clearChat(with peerID: String) {
|
||||
privateChats[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
/// Handle delivery acknowledgment
|
||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle read receipt
|
||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
||||
guard privateChats[peerID] != nil else { return }
|
||||
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
guard !sentReadReceipts.contains(message.id),
|
||||
let senderPeerID = message.senderPeerID else {
|
||||
return
|
||||
}
|
||||
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? "",
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
// Send through mesh service's read receipt method
|
||||
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Service to track processed messages across app restarts to prevent duplicates
|
||||
@MainActor
|
||||
final class ProcessedMessagesService {
|
||||
static let shared = ProcessedMessagesService()
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private let processedMessagesKey = "ProcessedNostrMessages"
|
||||
private let lastProcessedTimestampKey = "LastProcessedNostrTimestamp"
|
||||
private let maxStoredMessages = 1000 // Keep last 1000 message IDs
|
||||
|
||||
private var processedMessageIDs: Set<String> = []
|
||||
private var lastProcessedTimestamp: Date?
|
||||
|
||||
private init() {
|
||||
loadProcessedMessages()
|
||||
}
|
||||
|
||||
/// Check if a message has already been processed
|
||||
func isMessageProcessed(_ messageID: String) -> Bool {
|
||||
return processedMessageIDs.contains(messageID)
|
||||
}
|
||||
|
||||
/// Mark a message as processed
|
||||
func markMessageAsProcessed(_ messageID: String, timestamp: Date) {
|
||||
processedMessageIDs.insert(messageID)
|
||||
|
||||
// Update last processed timestamp if this message is newer
|
||||
if let lastTimestamp = lastProcessedTimestamp {
|
||||
if timestamp > lastTimestamp {
|
||||
lastProcessedTimestamp = timestamp
|
||||
}
|
||||
} else {
|
||||
lastProcessedTimestamp = timestamp
|
||||
}
|
||||
|
||||
// Trim if we have too many stored IDs
|
||||
if processedMessageIDs.count > maxStoredMessages {
|
||||
trimOldestMessages()
|
||||
}
|
||||
|
||||
saveProcessedMessages()
|
||||
}
|
||||
|
||||
/// Get the timestamp to use for Nostr subscription filters
|
||||
func getSubscriptionSinceDate() -> Date {
|
||||
// If we have a last processed timestamp, use it minus a small buffer
|
||||
if let lastTimestamp = lastProcessedTimestamp {
|
||||
// Go back 1 hour before last processed message for safety
|
||||
return lastTimestamp.addingTimeInterval(-3600)
|
||||
}
|
||||
|
||||
// Default: look back 24 hours on first run
|
||||
return Date().addingTimeInterval(-86400)
|
||||
}
|
||||
|
||||
/// Clear all processed messages (useful for debugging)
|
||||
func clearProcessedMessages() {
|
||||
processedMessageIDs.removeAll()
|
||||
lastProcessedTimestamp = nil
|
||||
saveProcessedMessages()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func loadProcessedMessages() {
|
||||
if let data = userDefaults.data(forKey: processedMessagesKey),
|
||||
let decoded = try? JSONDecoder().decode([String].self, from: data) {
|
||||
processedMessageIDs = Set(decoded)
|
||||
}
|
||||
|
||||
if let timestampInterval = userDefaults.object(forKey: lastProcessedTimestampKey) as? TimeInterval {
|
||||
lastProcessedTimestamp = Date(timeIntervalSince1970: timestampInterval)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func saveProcessedMessages() {
|
||||
// Convert Set to Array for encoding
|
||||
let messageArray = Array(processedMessageIDs)
|
||||
if let encoded = try? JSONEncoder().encode(messageArray) {
|
||||
userDefaults.set(encoded, forKey: processedMessagesKey)
|
||||
}
|
||||
|
||||
if let timestamp = lastProcessedTimestamp {
|
||||
userDefaults.set(timestamp.timeIntervalSince1970, forKey: lastProcessedTimestampKey)
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
private func trimOldestMessages() {
|
||||
// Since we don't track insertion order, we'll just keep the most recent N messages
|
||||
// In a production app, you might want to track timestamps for each message
|
||||
let excess = processedMessageIDs.count - maxStoredMessages
|
||||
if excess > 0 {
|
||||
// Remove random excess messages (not ideal, but simple)
|
||||
for _ in 0..<excess {
|
||||
if let first = processedMessageIDs.first {
|
||||
processedMessageIDs.remove(first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,405 @@
|
||||
//
|
||||
// UnifiedPeerService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Unified peer state management combining mesh connectivity and favorites
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import CryptoKit
|
||||
|
||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||
@MainActor
|
||||
class UnifiedPeerService: ObservableObject {
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var peers: [BitchatPeer] = []
|
||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
||||
@Published private(set) var favorites: [BitchatPeer] = []
|
||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
||||
private let meshService: SimplifiedBluetoothService
|
||||
private let favoritesService = FavoritesPersistenceService.shared
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(meshService: SimplifiedBluetoothService) {
|
||||
self.meshService = meshService
|
||||
|
||||
// Subscribe to changes from both services
|
||||
setupSubscriptions()
|
||||
|
||||
// Perform initial update
|
||||
Task { @MainActor in
|
||||
updatePeers()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupSubscriptions() {
|
||||
// Subscribe to mesh peer updates
|
||||
meshService.fullPeersPublisher
|
||||
.combineLatest(favoritesService.$favorites)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updatePeers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Also listen for favorite change notifications
|
||||
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updatePeers()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Core Update Logic
|
||||
|
||||
private func updatePeers() {
|
||||
let meshPeers = meshService.fullPeersPublisher.value
|
||||
let favorites = favoritesService.favorites
|
||||
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<String> = []
|
||||
var addedPeerIDs: Set<String> = []
|
||||
|
||||
// Phase 1: Add all connected mesh peers
|
||||
for (peerID, peerInfo) in meshPeers where peerInfo.isConnected {
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerID: peerID,
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites
|
||||
)
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
connected.insert(peerID)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
let peerID = favoriteKey.hexEncodedString()
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
// Update fingerprint cache
|
||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||
}
|
||||
|
||||
// Phase 3: Sort peers
|
||||
enrichedPeers.sort { lhs, rhs in
|
||||
// Connected first
|
||||
if lhs.isConnected != rhs.isConnected {
|
||||
return lhs.isConnected
|
||||
}
|
||||
// Then favorites
|
||||
if lhs.isFavorite != rhs.isFavorite {
|
||||
return lhs.isFavorite
|
||||
}
|
||||
// Finally alphabetical
|
||||
return lhs.displayName < rhs.displayName
|
||||
}
|
||||
|
||||
// Phase 4: Build subsets and indices
|
||||
var favoritesList: [BitchatPeer] = []
|
||||
var mutualsList: [BitchatPeer] = []
|
||||
var newIndex: [String: BitchatPeer] = [:]
|
||||
|
||||
for peer in enrichedPeers {
|
||||
newIndex[peer.id] = peer
|
||||
|
||||
if peer.isFavorite {
|
||||
favoritesList.append(peer)
|
||||
}
|
||||
if peer.isMutualFavorite {
|
||||
mutualsList.append(peer)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Update published properties
|
||||
self.peers = enrichedPeers
|
||||
self.connectedPeerIDs = connected
|
||||
self.favorites = favoritesList
|
||||
self.mutualFavorites = mutualsList
|
||||
self.peerIndex = newIndex
|
||||
|
||||
// Log summary (commented out to reduce noise)
|
||||
// let connectedCount = connected.count
|
||||
// let offlineCount = enrichedPeers.count - connectedCount
|
||||
// Peer update: \(enrichedPeers.count) total (\(connectedCount) connected, \(offlineCount) offline)
|
||||
}
|
||||
|
||||
// MARK: - Peer Building Helpers
|
||||
|
||||
private func buildPeerFromMesh(
|
||||
peerID: String,
|
||||
peerInfo: SimplifiedBluetoothService.PeerInfoSnapshot,
|
||||
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
isConnected: true
|
||||
)
|
||||
|
||||
// Check for favorite status
|
||||
if let noiseKey = peerInfo.noisePublicKey,
|
||||
let favoriteStatus = favorites[noiseKey] {
|
||||
peer.favoriteStatus = favoriteStatus
|
||||
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
|
||||
} else {
|
||||
// Check by nickname for reconnected peers
|
||||
let favoriteByNickname = favorites.values.first {
|
||||
$0.peerNickname == peerInfo.nickname
|
||||
}
|
||||
|
||||
if let favorite = favoriteByNickname,
|
||||
let noiseKey = peerInfo.noisePublicKey {
|
||||
SecureLogger.log(
|
||||
"🔄 Found favorite for '\(peerInfo.nickname)' by nickname, updating noise key",
|
||||
category: SecureLogger.session,
|
||||
level: .info
|
||||
)
|
||||
|
||||
// Update the favorite's key in persistence
|
||||
favoritesService.updateNoisePublicKey(
|
||||
from: favorite.peerNoisePublicKey,
|
||||
to: noiseKey,
|
||||
peerNickname: peerInfo.nickname
|
||||
)
|
||||
|
||||
// Get updated favorite
|
||||
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
|
||||
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
|
||||
}
|
||||
}
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
private func buildPeerFromFavorite(
|
||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||
peerID: String
|
||||
) -> BitchatPeer {
|
||||
var peer = BitchatPeer(
|
||||
id: peerID,
|
||||
noisePublicKey: favorite.peerNoisePublicKey,
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
isConnected: false
|
||||
)
|
||||
|
||||
peer.favoriteStatus = favorite
|
||||
peer.nostrPublicKey = favorite.peerNostrPublicKey
|
||||
|
||||
return peer
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get peer by ID
|
||||
func getPeer(by id: String) -> BitchatPeer? {
|
||||
return peerIndex[id]
|
||||
}
|
||||
|
||||
/// Get peer ID for nickname
|
||||
func getPeerID(for nickname: String) -> String? {
|
||||
for peer in peers {
|
||||
if peer.displayName == nickname || peer.nickname == nickname {
|
||||
return peer.id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if peer is online
|
||||
func isOnline(_ peerID: String) -> Bool {
|
||||
return connectedPeerIDs.contains(peerID)
|
||||
}
|
||||
|
||||
/// Check if peer is blocked
|
||||
func isBlocked(_ peerID: String) -> Bool {
|
||||
// Get fingerprint
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||
|
||||
// Check SecureIdentityStateManager for block status
|
||||
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
||||
return identity.isBlocked
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: String) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
SecureLogger.log("⚠️ Cannot toggle favorite - peer not found: \(peerID)",
|
||||
category: SecureLogger.session, level: .warning)
|
||||
return
|
||||
}
|
||||
|
||||
let wasFavorite = peer.isFavorite
|
||||
|
||||
// Get the actual nickname for logging and saving
|
||||
var actualNickname = peer.nickname
|
||||
|
||||
// Debug logging to understand the issue
|
||||
SecureLogger.log("🔍 Toggle favorite - peer.nickname: '\(peer.nickname)', peer.displayName: '\(peer.displayName)', peerID: \(peerID)",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
if actualNickname.isEmpty {
|
||||
// Try to get from mesh service's current peer list
|
||||
if let meshPeerNickname = meshService.getPeerNicknames()[peerID] {
|
||||
actualNickname = meshPeerNickname
|
||||
SecureLogger.log("🔍 Got nickname from mesh service: '\(actualNickname)'",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
// Use displayName as fallback (which shows ID prefix if nickname is empty)
|
||||
let finalNickname = actualNickname.isEmpty ? peer.displayName : actualNickname
|
||||
|
||||
if wasFavorite {
|
||||
// Remove favorite
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
} else {
|
||||
// Get or derive peer's Nostr public key if not already known
|
||||
var peerNostrKey = peer.nostrPublicKey
|
||||
if peerNostrKey == nil {
|
||||
// Try to get from NostrIdentityBridge association
|
||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||
}
|
||||
|
||||
// Add favorite
|
||||
favoritesService.addFavorite(
|
||||
peerNoisePublicKey: peer.noisePublicKey,
|
||||
peerNostrPublicKey: peerNostrKey,
|
||||
peerNickname: finalNickname
|
||||
)
|
||||
}
|
||||
|
||||
// Log the final nickname being saved
|
||||
SecureLogger.log("⭐️ Toggled favorite for '\(finalNickname)' (peerID: \(peerID), was: \(wasFavorite), now: \(!wasFavorite))",
|
||||
category: SecureLogger.session, level: .info)
|
||||
|
||||
// Send favorite notification to the peer
|
||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||
|
||||
// Force update of peers to reflect the change
|
||||
updatePeers()
|
||||
|
||||
// Force UI update by notifying SwiftUI directly
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle blocked status
|
||||
func toggleBlocked(_ peerID: String) {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
||||
|
||||
// Get or create social identity
|
||||
var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint)
|
||||
?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
|
||||
// Toggle blocked status
|
||||
identity.isBlocked = !identity.isBlocked
|
||||
|
||||
// Can't be both favorite and blocked
|
||||
if identity.isBlocked {
|
||||
identity.isFavorite = false
|
||||
// Also remove from favorites service
|
||||
if let peer = getPeer(by: peerID) {
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
||||
}
|
||||
|
||||
/// Get fingerprint for peer ID
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
// Check cache first
|
||||
if let cached = fingerprintCache[peerID] {
|
||||
return cached
|
||||
}
|
||||
|
||||
// Try to get from mesh service
|
||||
if let fingerprint = meshService.getPeerFingerprint(peerID) {
|
||||
fingerprintCache[peerID] = fingerprint
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
// Try to get from peer's public key
|
||||
if let peer = getPeer(by: peerID) {
|
||||
let fingerprint = peer.noisePublicKey.sha256Fingerprint()
|
||||
fingerprintCache[peerID] = fingerprint
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Compatibility Methods (for easy migration)
|
||||
|
||||
var allPeers: [BitchatPeer] { peers }
|
||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
||||
var favoritePeers: Set<String> {
|
||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
||||
}
|
||||
var blockedUsers: Set<String> {
|
||||
Set(peers.compactMap { peer in
|
||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension Data {
|
||||
func sha256Fingerprint() -> String {
|
||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||
let hash = SHA256.hash(data: self)
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user