mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:25:19 +00:00
- Fix asymmetric handshake state causing message delivery failures - Prevent duplicate handshake init messages from disrupting ongoing handshakes - Add defensive copying to all binary decoders to prevent thread safety issues - Implement binary encoding for all 9 message types (60-80% bandwidth reduction) - Fix delivery ACK decoding for Noise encrypted messages - Add comprehensive logging for debugging handshake and message flow - Fix race condition in delivery status updates - Add relay logic for handshake packets to ensure mesh delivery - Maintain backward compatibility with JSON fallback
3872 lines
167 KiB
Swift
3872 lines
167 KiB
Swift
//
|
|
// ChatViewModel.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import Combine
|
|
import CryptoKit
|
|
import CommonCrypto
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
enum ChannelVerificationStatus {
|
|
case unverified
|
|
case verifying
|
|
case verified
|
|
case failed
|
|
}
|
|
|
|
class ChatViewModel: ObservableObject {
|
|
@Published var messages: [BitchatMessage] = []
|
|
@Published var connectedPeers: [String] = []
|
|
@Published var nickname: String = "" {
|
|
didSet {
|
|
nicknameSaveTimer?.invalidate()
|
|
nicknameSaveTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
|
|
self.saveNickname()
|
|
}
|
|
}
|
|
}
|
|
@Published var isConnected = false
|
|
@Published var privateChats: [String: [BitchatMessage]] = [:] // peerID -> messages
|
|
@Published var selectedPrivateChatPeer: String? = nil
|
|
private var selectedPrivateChatFingerprint: String? = nil // Track by fingerprint for persistence across reconnections
|
|
@Published var unreadPrivateMessages: Set<String> = []
|
|
@Published var autocompleteSuggestions: [String] = []
|
|
@Published var showAutocomplete: Bool = false
|
|
@Published var autocompleteRange: NSRange? = nil
|
|
@Published var selectedAutocompleteIndex: Int = 0
|
|
|
|
// Channel support
|
|
@Published var joinedChannels: Set<String> = [] // Set of channel hashtags
|
|
@Published var currentChannel: String? = nil // Currently selected channel
|
|
@Published var channelMessages: [String: [BitchatMessage]] = [:] // channel -> messages
|
|
@Published var unreadChannelMessages: [String: Int] = [:] // channel -> unread count
|
|
@Published var channelMembers: [String: Set<String>] = [:] // channel -> set of peer IDs who have sent messages
|
|
@Published var channelPasswords: [String: String] = [:] // channel -> password (stored locally only)
|
|
@Published var channelKeys: [String: SymmetricKey] = [:] // channel -> derived encryption key
|
|
@Published var passwordProtectedChannels: Set<String> = [] // Set of channels that require passwords
|
|
@Published var channelCreators: [String: String] = [:] // channel -> creator peerID
|
|
@Published var channelKeyCommitments: [String: String] = [:] // channel -> SHA256(derivedKey) for verification
|
|
@Published var channelMetadata: [String: ChannelMetadata] = [:] // channel -> metadata from creator
|
|
@Published var showPasswordPrompt: Bool = false
|
|
@Published var passwordPromptChannel: String? = nil
|
|
@Published var channelVerificationStatus: [String: ChannelVerificationStatus] = [:] // Track verification status
|
|
|
|
var meshService = BluetoothMeshService()
|
|
private let userDefaults = UserDefaults.standard
|
|
private let nicknameKey = "bitchat.nickname"
|
|
private let joinedChannelsKey = "bitchat.joinedChannels"
|
|
private let passwordProtectedChannelsKey = "bitchat.passwordProtectedChannels"
|
|
private let channelCreatorsKey = "bitchat.channelCreators"
|
|
// private let channelPasswordsKey = "bitchat.channelPasswords" // Now using Keychain
|
|
private let channelKeyCommitmentsKey = "bitchat.channelKeyCommitments"
|
|
private var nicknameSaveTimer: Timer?
|
|
|
|
@Published var favoritePeers: Set<String> = [] // Now stores public key fingerprints instead of peer IDs
|
|
private var peerIDToPublicKeyFingerprint: [String: String] = [:] // Maps ephemeral peer IDs to persistent fingerprints
|
|
private var blockedUsers: Set<String> = [] // Stores public key fingerprints of blocked users
|
|
|
|
// Noise Protocol encryption status
|
|
@Published var peerEncryptionStatus: [String: EncryptionStatus] = [:] // peerID -> encryption status
|
|
@Published var verifiedFingerprints: Set<String> = [] // Set of verified fingerprints
|
|
@Published var showingFingerprintFor: String? = nil // Currently showing fingerprint sheet for peer
|
|
|
|
// Messages are naturally ephemeral - no persistent storage
|
|
|
|
// Delivery tracking
|
|
private var deliveryTrackerCancellable: AnyCancellable?
|
|
|
|
// Track sent read receipts to avoid duplicates
|
|
private var sentReadReceipts: Set<String> = [] // messageID set
|
|
|
|
init() {
|
|
loadNickname()
|
|
loadFavorites()
|
|
loadJoinedChannels()
|
|
loadChannelData()
|
|
loadBlockedUsers()
|
|
loadVerifiedFingerprints()
|
|
meshService.delegate = self
|
|
|
|
// Log startup info
|
|
|
|
// Log fingerprint after a delay to ensure encryption service is ready
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
if let self = self {
|
|
_ = self.getMyFingerprint()
|
|
}
|
|
}
|
|
|
|
// Start mesh service immediately
|
|
meshService.startServices()
|
|
|
|
// Set up message retry service
|
|
MessageRetryService.shared.meshService = meshService
|
|
|
|
// Set up Noise encryption callbacks
|
|
setupNoiseCallbacks()
|
|
|
|
// Request notification permission
|
|
NotificationService.shared.requestAuthorization()
|
|
|
|
// Subscribe to delivery status updates
|
|
deliveryTrackerCancellable = DeliveryTracker.shared.deliveryStatusUpdated
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] (messageID, status) in
|
|
self?.updateMessageDeliveryStatus(messageID, status: status)
|
|
}
|
|
|
|
// Show welcome message after delay if still no peers
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
guard let self = self else { return }
|
|
if self.connectedPeers.isEmpty && self.messages.isEmpty {
|
|
let welcomeMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "get people around you to download bitchat…and chat with them here!",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
self.messages.append(welcomeMessage)
|
|
}
|
|
}
|
|
|
|
// When app becomes active, send read receipts for visible messages
|
|
#if os(macOS)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appDidBecomeActive),
|
|
name: NSApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Add app lifecycle observers to save data
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillResignActive),
|
|
name: NSApplication.willResignActiveNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillTerminate),
|
|
name: NSApplication.willTerminateNotification,
|
|
object: nil
|
|
)
|
|
#else
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appDidBecomeActive),
|
|
name: UIApplication.didBecomeActiveNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Add screenshot detection for iOS
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(userDidTakeScreenshot),
|
|
name: UIApplication.userDidTakeScreenshotNotification,
|
|
object: nil
|
|
)
|
|
|
|
// Add app lifecycle observers to save data
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillResignActive),
|
|
name: UIApplication.willResignActiveNotification,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(appWillTerminate),
|
|
name: UIApplication.willTerminateNotification,
|
|
object: nil
|
|
)
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
// Ensure channel ownership data is saved
|
|
saveChannelData()
|
|
// Force immediate save
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
private func loadNickname() {
|
|
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
|
|
nickname = savedNickname
|
|
} else {
|
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
|
saveNickname()
|
|
}
|
|
}
|
|
|
|
func saveNickname() {
|
|
userDefaults.set(nickname, forKey: nicknameKey)
|
|
userDefaults.synchronize() // Force immediate save
|
|
|
|
// Send announce with new nickname to all peers
|
|
meshService.sendBroadcastAnnounce()
|
|
}
|
|
|
|
private func loadFavorites() {
|
|
// Load favorites from secure storage
|
|
favoritePeers = SecureIdentityStateManager.shared.getFavorites()
|
|
}
|
|
|
|
private func saveFavorites() {
|
|
// Favorites are now saved automatically in SecureIdentityStateManager
|
|
// This method is kept for compatibility
|
|
}
|
|
|
|
private func loadBlockedUsers() {
|
|
// Load blocked users from secure storage
|
|
let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities()
|
|
blockedUsers = Set(allIdentities.filter { $0.isBlocked }.map { $0.fingerprint })
|
|
}
|
|
|
|
private func saveBlockedUsers() {
|
|
// Blocked users are now saved automatically in SecureIdentityStateManager
|
|
// This method is kept for compatibility
|
|
}
|
|
|
|
private func loadJoinedChannels() {
|
|
if let savedChannelsList = userDefaults.stringArray(forKey: joinedChannelsKey) {
|
|
joinedChannels = Set(savedChannelsList)
|
|
// Initialize empty data structures for joined channels
|
|
for channel in joinedChannels {
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
if channelMembers[channel] == nil {
|
|
channelMembers[channel] = Set()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveJoinedChannels() {
|
|
userDefaults.set(Array(joinedChannels), forKey: joinedChannelsKey)
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
private func loadChannelData() {
|
|
// Load password protected channels
|
|
if let savedProtectedChannels = userDefaults.stringArray(forKey: passwordProtectedChannelsKey) {
|
|
passwordProtectedChannels = Set(savedProtectedChannels)
|
|
}
|
|
|
|
// Load channel creators
|
|
if let savedCreators = userDefaults.dictionary(forKey: channelCreatorsKey) as? [String: String] {
|
|
channelCreators = savedCreators
|
|
|
|
// Clean up any legacy ownership records
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.cleanupLegacyChannelOwnership()
|
|
}
|
|
} else {
|
|
}
|
|
|
|
// Load channel key commitments
|
|
if let savedCommitments = userDefaults.dictionary(forKey: channelKeyCommitmentsKey) as? [String: String] {
|
|
channelKeyCommitments = savedCommitments
|
|
}
|
|
|
|
// Load channel passwords from Keychain
|
|
let savedPasswords = KeychainManager.shared.getAllChannelPasswords()
|
|
channelPasswords = savedPasswords
|
|
// Derive keys for all saved passwords
|
|
for (channel, password) in savedPasswords {
|
|
channelKeys[channel] = deriveChannelKey(from: password, channelName: channel)
|
|
// Also load password into NoiseEncryptionService
|
|
_ = meshService.getNoiseService().loadChannelPassword(for: channel)
|
|
}
|
|
|
|
// Also check if passwords were saved but not loaded for password-protected channels
|
|
for channel in passwordProtectedChannels {
|
|
if !channelPasswords.keys.contains(channel) {
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveChannelData() {
|
|
|
|
userDefaults.set(Array(passwordProtectedChannels), forKey: passwordProtectedChannelsKey)
|
|
userDefaults.set(channelCreators, forKey: channelCreatorsKey)
|
|
// Save passwords to Keychain instead of UserDefaults
|
|
for (channel, password) in channelPasswords {
|
|
_ = KeychainManager.shared.saveChannelPassword(password, for: channel)
|
|
}
|
|
userDefaults.set(channelKeyCommitments, forKey: channelKeyCommitmentsKey)
|
|
|
|
// Force synchronize and add a small delay to ensure writes complete
|
|
_ = userDefaults.synchronize()
|
|
|
|
// Verify save worked
|
|
_ = userDefaults.dictionary(forKey: channelCreatorsKey) as? [String: String] != nil
|
|
}
|
|
|
|
func joinChannel(_ channel: String, password: String? = nil) -> Bool {
|
|
// Ensure channel starts with #
|
|
let channelTag = channel.hasPrefix("#") ? channel : "#\(channel)"
|
|
|
|
|
|
// Check if channel is already joined and we can access it
|
|
if joinedChannels.contains(channelTag) {
|
|
// Already joined, check if we need password verification
|
|
if passwordProtectedChannels.contains(channelTag) && channelKeys[channelTag] == nil {
|
|
if let password = password {
|
|
// User provided password for already-joined channel - verify it
|
|
|
|
// Derive key and try to verify
|
|
let key = deriveChannelKey(from: password, channelName: channelTag)
|
|
|
|
// First, check if we have a key commitment to verify against
|
|
if let expectedCommitment = channelKeyCommitments[channelTag] {
|
|
let actualCommitment = computeKeyCommitment(for: key)
|
|
if actualCommitment != expectedCommitment {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if we have messages to verify against
|
|
if let channelMsgs = channelMessages[channelTag], !channelMsgs.isEmpty {
|
|
let encryptedMessages = channelMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
|
|
if let encryptedMsg = encryptedMessages.first,
|
|
let encryptedData = encryptedMsg.encryptedContent {
|
|
let testDecrypted = decryptChannelMessage(encryptedData, channel: channelTag, testKey: key)
|
|
if testDecrypted == nil {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store the verified key
|
|
channelKeys[channelTag] = key
|
|
channelPasswords[channelTag] = password
|
|
|
|
// Now switch to the channel
|
|
switchToChannel(channelTag)
|
|
return true
|
|
} else {
|
|
// Need password to access
|
|
passwordPromptChannel = channelTag
|
|
showPasswordPrompt = true
|
|
return false
|
|
}
|
|
}
|
|
// Switch to the channel (no password needed)
|
|
switchToChannel(channelTag)
|
|
return true
|
|
}
|
|
|
|
// If channel is password protected and we don't have the key yet
|
|
if passwordProtectedChannels.contains(channelTag) && channelKeys[channelTag] == nil {
|
|
// Allow channel creator to bypass password check
|
|
let myFingerprint = getMyFingerprint()
|
|
if channelCreators[channelTag] == meshService.myPeerID || channelCreators[channelTag] == myFingerprint {
|
|
// Channel creator should already have the key set when they created the password
|
|
// This is a failsafe - just proceed without password
|
|
} else if let password = password {
|
|
// Derive key from password
|
|
let key = deriveChannelKey(from: password, channelName: channelTag)
|
|
|
|
// First, check if we have a key commitment to verify against
|
|
if let expectedCommitment = channelKeyCommitments[channelTag] {
|
|
let actualCommitment = computeKeyCommitment(for: key)
|
|
if actualCommitment != expectedCommitment {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Try to verify password if there are existing encrypted messages
|
|
var passwordVerified = false
|
|
var shouldProceed = true
|
|
|
|
if let channelMsgs = channelMessages[channelTag], !channelMsgs.isEmpty {
|
|
// Look for encrypted messages to verify against
|
|
let encryptedMessages = channelMsgs.filter { $0.isEncrypted && $0.encryptedContent != nil }
|
|
|
|
if let encryptedMsg = encryptedMessages.first,
|
|
let encryptedData = encryptedMsg.encryptedContent {
|
|
// Test decryption with the derived key
|
|
let testDecrypted = decryptChannelMessage(encryptedData, channel: channelTag, testKey: key)
|
|
if testDecrypted == nil {
|
|
// Password is wrong, can't decrypt
|
|
shouldProceed = false
|
|
} else {
|
|
passwordVerified = true
|
|
}
|
|
} else {
|
|
// No encrypted messages yet - accept tentatively
|
|
|
|
// Add warning message
|
|
let warningMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "joined channel \(channelTag). password will be verified when encrypted messages arrive.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(warningMsg)
|
|
}
|
|
} else {
|
|
// Empty channel - accept tentatively
|
|
|
|
// Add info message
|
|
let infoMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "joined empty channel \(channelTag). waiting for encrypted messages to verify password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(infoMsg)
|
|
}
|
|
|
|
// Only proceed if password verification didn't fail
|
|
if !shouldProceed {
|
|
return false
|
|
}
|
|
|
|
// Store the key (tentatively if not verified)
|
|
channelKeys[channelTag] = key
|
|
channelPasswords[channelTag] = password
|
|
// Save password to Keychain
|
|
_ = KeychainManager.shared.saveChannelPassword(password, for: channelTag)
|
|
|
|
if passwordVerified {
|
|
// Password verified locally
|
|
// Password verified locally
|
|
} else {
|
|
// Send key verification request to channel members after a short delay
|
|
// to allow channel member list to populate
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.sendChannelKeyVerificationRequest(for: channelTag)
|
|
}
|
|
}
|
|
} else {
|
|
// Show password prompt and return early - don't join the channel yet
|
|
passwordPromptChannel = channelTag
|
|
showPasswordPrompt = true
|
|
return false
|
|
}
|
|
}
|
|
|
|
// At this point, channel is either not password protected or we don't know yet
|
|
|
|
joinedChannels.insert(channelTag)
|
|
saveJoinedChannels()
|
|
|
|
// Only claim creator role if this is a brand new channel (no one has announced it as protected)
|
|
// If it's password protected, someone else already created it
|
|
// Also check if we have metadata from another creator
|
|
let hasExistingMetadata = channelMetadata[channelTag] != nil
|
|
let isNewChannel = channelCreators[channelTag] == nil && !passwordProtectedChannels.contains(channelTag) && !hasExistingMetadata
|
|
if isNewChannel {
|
|
// Use persistent fingerprint for ownership instead of ephemeral peer ID
|
|
let myFingerprint = getMyFingerprint()
|
|
// Creating new channel
|
|
|
|
if myFingerprint != "Unknown" {
|
|
channelCreators[channelTag] = myFingerprint
|
|
// Set creator to fingerprint
|
|
saveChannelData()
|
|
} else {
|
|
// Fallback to peer ID if encryption service not ready yet
|
|
channelCreators[channelTag] = meshService.myPeerID
|
|
// Fingerprint not ready, using peer ID temporarily
|
|
saveChannelData()
|
|
// Try to update to fingerprint after a delay
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
if let self = self {
|
|
let updatedFingerprint = self.getMyFingerprint()
|
|
if updatedFingerprint != "Unknown" && self.channelCreators[channelTag] == self.meshService.myPeerID {
|
|
self.channelCreators[channelTag] = updatedFingerprint
|
|
// Updated creator to fingerprint
|
|
self.saveChannelData()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Broadcasting channel creation to public chat
|
|
|
|
// Broadcast channel metadata to all peers
|
|
let metadata = ChannelMetadata(
|
|
channel: channelTag,
|
|
creatorID: meshService.myPeerID,
|
|
creatorFingerprint: myFingerprint,
|
|
isPasswordProtected: password != nil,
|
|
keyCommitment: password != nil ? computeKeyCommitment(for: deriveChannelKey(from: password!, channelName: channelTag)) : nil
|
|
)
|
|
meshService.sendChannelMetadata(metadata)
|
|
|
|
// Add system message to show channel creation
|
|
let sysMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "you created channel \(channelTag). announcing to public chat...",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(sysMsg)
|
|
|
|
// Announce channel creation in public chat (delay to ensure proper context)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self = self else { return }
|
|
// Store current channel temporarily
|
|
let previousChannel = self.currentChannel
|
|
self.currentChannel = nil // Ensure we're in public chat
|
|
|
|
// Send announcement
|
|
let announcement = "created channel \(channelTag)"
|
|
self.sendMessage(announcement)
|
|
|
|
// Restore channel context
|
|
self.currentChannel = previousChannel
|
|
}
|
|
}
|
|
|
|
// Add ourselves as a member
|
|
if channelMembers[channelTag] == nil {
|
|
channelMembers[channelTag] = Set()
|
|
}
|
|
channelMembers[channelTag]?.insert(meshService.myPeerID)
|
|
|
|
// If password protected, also add any connected peers who might be in the channel
|
|
// They'll be properly verified when they send messages
|
|
if passwordProtectedChannels.contains(channelTag) {
|
|
for peerID in connectedPeers {
|
|
channelMembers[channelTag]?.insert(peerID)
|
|
}
|
|
}
|
|
|
|
// Switch to the channel
|
|
currentChannel = channelTag
|
|
selectedPrivateChatPeer = nil // Exit private chat if in one
|
|
|
|
// Clear unread count for this channel
|
|
unreadChannelMessages[channelTag] = 0
|
|
|
|
// Initialize channel messages if needed
|
|
if channelMessages[channelTag] == nil {
|
|
channelMessages[channelTag] = []
|
|
}
|
|
|
|
// Hide password prompt if it was showing
|
|
showPasswordPrompt = false
|
|
passwordPromptChannel = nil
|
|
|
|
// If this is a password protected channel, trigger verification
|
|
if passwordProtectedChannels.contains(channelTag) && channelKeys[channelTag] != nil {
|
|
// Send verification request after a short delay to allow peers to see us join
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.sendChannelKeyVerificationRequest(for: channelTag)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func leaveChannel(_ channel: String) {
|
|
joinedChannels.remove(channel)
|
|
saveJoinedChannels()
|
|
|
|
// Send leave notification to other peers
|
|
meshService.sendChannelLeaveNotification(channel)
|
|
|
|
// If we're currently in this channel, exit to main chat
|
|
if currentChannel == channel {
|
|
currentChannel = nil
|
|
}
|
|
|
|
// Clean up channel data
|
|
unreadChannelMessages.removeValue(forKey: channel)
|
|
channelMessages.removeValue(forKey: channel)
|
|
channelMembers.removeValue(forKey: channel)
|
|
channelKeys.removeValue(forKey: channel)
|
|
channelPasswords.removeValue(forKey: channel)
|
|
// Delete password from Keychain
|
|
_ = KeychainManager.shared.deleteChannelPassword(for: channel)
|
|
}
|
|
|
|
// Password management
|
|
func setChannelPassword(_ password: String, for channel: String) {
|
|
guard joinedChannels.contains(channel) else { return }
|
|
|
|
// Check if channel already has a creator
|
|
let myFingerprint = getMyFingerprint()
|
|
if let existingCreator = channelCreators[channel],
|
|
existingCreator != meshService.myPeerID && existingCreator != myFingerprint {
|
|
return
|
|
}
|
|
|
|
// If channel is already password protected by someone else, we can't claim it
|
|
if passwordProtectedChannels.contains(channel) &&
|
|
channelCreators[channel] != meshService.myPeerID && channelCreators[channel] != myFingerprint {
|
|
return
|
|
}
|
|
|
|
// Claim creator role if not set and channel is not already protected
|
|
if channelCreators[channel] == nil && !passwordProtectedChannels.contains(channel) {
|
|
if myFingerprint != "Unknown" {
|
|
channelCreators[channel] = myFingerprint
|
|
// Set creator to fingerprint
|
|
} else {
|
|
channelCreators[channel] = meshService.myPeerID
|
|
|
|
// Try to update to fingerprint after a delay
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
if let self = self {
|
|
let updatedFingerprint = self.getMyFingerprint()
|
|
if updatedFingerprint != "Unknown" && self.channelCreators[channel] == self.meshService.myPeerID {
|
|
self.channelCreators[channel] = updatedFingerprint
|
|
// Updated creator to fingerprint
|
|
self.saveChannelData()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
saveChannelData()
|
|
}
|
|
|
|
// Derive encryption key from password
|
|
let key = deriveChannelKey(from: password, channelName: channel)
|
|
channelKeys[channel] = key
|
|
channelPasswords[channel] = password
|
|
passwordProtectedChannels.insert(channel)
|
|
// Save password to Keychain
|
|
_ = KeychainManager.shared.saveChannelPassword(password, for: channel)
|
|
|
|
// Compute and store key commitment for verification
|
|
let commitment = computeKeyCommitment(for: key)
|
|
channelKeyCommitments[channel] = commitment
|
|
|
|
// Save channel data
|
|
saveChannelData()
|
|
|
|
// Announce that this channel is now password protected with commitment
|
|
// Use fingerprint if available, otherwise peer ID
|
|
let creatorID = myFingerprint != "Unknown" ? myFingerprint : meshService.myPeerID
|
|
meshService.announcePasswordProtectedChannel(channel, creatorID: creatorID, keyCommitment: commitment)
|
|
|
|
// Send an encrypted initialization message with metadata
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let metadata = [
|
|
"type": "channel_init",
|
|
"channel": channel,
|
|
"creator": nickname,
|
|
"creatorID": meshService.myPeerID,
|
|
"timestamp": timestamp,
|
|
"version": "1.0"
|
|
]
|
|
let jsonData = try? JSONSerialization.data(withJSONObject: metadata)
|
|
let metadataStr = jsonData?.base64EncodedString() ?? ""
|
|
|
|
let initMessage = "🔐 Channel \(channel) initialized | Protected channel created by \(nickname) | Metadata: \(metadataStr)"
|
|
meshService.sendEncryptedChannelMessage(initMessage, mentions: [], channel: channel, channelKey: key)
|
|
|
|
}
|
|
|
|
func removeChannelPassword(for channel: String) {
|
|
// Only channel creator can remove password
|
|
guard channelCreators[channel] == meshService.myPeerID else {
|
|
return
|
|
}
|
|
|
|
channelKeys.removeValue(forKey: channel)
|
|
channelPasswords.removeValue(forKey: channel)
|
|
channelKeyCommitments.removeValue(forKey: channel)
|
|
passwordProtectedChannels.remove(channel)
|
|
// Delete password from Keychain
|
|
_ = KeychainManager.shared.deleteChannelPassword(for: channel)
|
|
|
|
// Save channel data
|
|
saveChannelData()
|
|
|
|
// Announce that this channel is no longer password protected
|
|
meshService.announcePasswordProtectedChannel(channel, isProtected: false, creatorID: meshService.myPeerID)
|
|
|
|
}
|
|
|
|
// Transfer channel ownership to another user
|
|
func transferChannelOwnership(to nickname: String) {
|
|
guard let currentChannel = currentChannel else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "you must be in a channel to transfer ownership.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Check if current user is the owner using fingerprint
|
|
guard isChannelOwner(currentChannel) else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "only the channel owner can transfer ownership.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Remove @ prefix if present
|
|
let targetNick = nickname.hasPrefix("@") ? String(nickname.dropFirst()) : nickname
|
|
|
|
// Find peer ID for the nickname
|
|
guard let targetPeerID = getPeerIDForNickname(targetNick) else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "user \(targetNick) not found. they must be online to receive ownership.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Get the fingerprint of the target peer
|
|
guard let targetFingerprint = getFingerprint(for: targetPeerID), targetFingerprint != "Unknown" else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot transfer ownership: \(targetNick) does not have a verified identity.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Update ownership to target's fingerprint
|
|
channelCreators[currentChannel] = targetFingerprint
|
|
saveChannelData()
|
|
|
|
// Broadcast updated channel metadata with new owner
|
|
let metadata = ChannelMetadata(
|
|
channel: currentChannel,
|
|
creatorID: targetPeerID,
|
|
creatorFingerprint: targetFingerprint,
|
|
isPasswordProtected: passwordProtectedChannels.contains(currentChannel),
|
|
keyCommitment: channelKeyCommitments[currentChannel]
|
|
)
|
|
meshService.sendChannelMetadata(metadata)
|
|
|
|
// Also announce if password protected
|
|
if passwordProtectedChannels.contains(currentChannel) {
|
|
let commitment = channelKeyCommitments[currentChannel]
|
|
meshService.announcePasswordProtectedChannel(currentChannel, creatorID: targetFingerprint, keyCommitment: commitment)
|
|
}
|
|
|
|
// Send notification message
|
|
let transferMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "channel ownership transferred from \(self.nickname) to \(targetNick).",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
channel: currentChannel
|
|
)
|
|
messages.append(transferMsg)
|
|
|
|
// Send encrypted notification if channel is protected
|
|
if let channelKey = channelKeys[currentChannel] {
|
|
let notifyMsg = "🔑 Channel ownership transferred to \(targetNick) by \(self.nickname)"
|
|
meshService.sendEncryptedChannelMessage(notifyMsg, mentions: [targetNick], channel: currentChannel, channelKey: channelKey)
|
|
} else {
|
|
meshService.sendMessage(transferMsg.content, mentions: [targetNick])
|
|
}
|
|
|
|
}
|
|
|
|
// Change password for current channel
|
|
func changeChannelPassword(to newPassword: String) {
|
|
guard let currentChannel = currentChannel else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "you must be in a channel to change its password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Check if current user is the owner using fingerprint
|
|
guard isChannelOwner(currentChannel) else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "only the channel owner can change the password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Check if channel is currently password protected
|
|
guard passwordProtectedChannels.contains(currentChannel) else {
|
|
let msg = BitchatMessage(
|
|
sender: "system",
|
|
content: "channel is not password protected. use the lock button to set a password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(msg)
|
|
return
|
|
}
|
|
|
|
// Store old key for re-encryption
|
|
let oldKey = channelKeys[currentChannel]
|
|
|
|
// Derive new encryption key from new password
|
|
let newKey = deriveChannelKey(from: newPassword, channelName: currentChannel)
|
|
channelKeys[currentChannel] = newKey
|
|
channelPasswords[currentChannel] = newPassword
|
|
// Update password in Keychain
|
|
_ = KeychainManager.shared.saveChannelPassword(newPassword, for: currentChannel)
|
|
|
|
// Compute new key commitment
|
|
let newCommitment = computeKeyCommitment(for: newKey)
|
|
channelKeyCommitments[currentChannel] = newCommitment
|
|
|
|
// Save channel data
|
|
saveChannelData()
|
|
|
|
// Send password change notification with old key
|
|
if let oldKey = oldKey {
|
|
let changeNotice = "🔐 Password changed by channel owner. Please update your password."
|
|
meshService.sendEncryptedChannelMessage(changeNotice, mentions: [], channel: currentChannel, channelKey: oldKey)
|
|
}
|
|
|
|
// Send new initialization message with new key
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let changeMetadata = [
|
|
"type": "password_change",
|
|
"channel": currentChannel,
|
|
"changer": nickname,
|
|
"changerID": meshService.myPeerID,
|
|
"timestamp": timestamp,
|
|
"version": "1.0"
|
|
]
|
|
let jsonData = try? JSONSerialization.data(withJSONObject: changeMetadata)
|
|
let metadataStr = jsonData?.base64EncodedString() ?? ""
|
|
|
|
let initMessage = "🔑 Password changed | Channel \(currentChannel) password updated by \(nickname) | Metadata: \(metadataStr)"
|
|
meshService.sendEncryptedChannelMessage(initMessage, mentions: [], channel: currentChannel, channelKey: newKey)
|
|
|
|
// Announce the new commitment with fingerprint
|
|
let myFingerprint = getMyFingerprint()
|
|
meshService.announcePasswordProtectedChannel(currentChannel, creatorID: myFingerprint, keyCommitment: newCommitment)
|
|
|
|
// Broadcast updated channel metadata
|
|
let metadata = ChannelMetadata(
|
|
channel: currentChannel,
|
|
creatorID: meshService.myPeerID,
|
|
creatorFingerprint: myFingerprint,
|
|
isPasswordProtected: true,
|
|
keyCommitment: newCommitment
|
|
)
|
|
meshService.sendChannelMetadata(metadata)
|
|
|
|
// Send new password to connected channel members via Noise
|
|
distributeChannelPasswordUpdate(channel: currentChannel, newPassword: newPassword, newCommitment: newCommitment)
|
|
|
|
// Add local success message
|
|
let successMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "password changed successfully. other users will need to re-enter the new password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(successMsg)
|
|
|
|
}
|
|
|
|
private func distributeChannelPasswordUpdate(channel: String, newPassword: String, newCommitment: String) {
|
|
// Get channel members
|
|
let members = getChannelMembers(channel)
|
|
|
|
// Filter to only connected members with Noise sessions
|
|
let noiseService = meshService.getNoiseService()
|
|
let connectedMembersWithNoise = members.filter { peerID in
|
|
peerID != meshService.myPeerID &&
|
|
connectedPeers.contains(peerID) &&
|
|
noiseService.hasEstablishedSession(with: peerID)
|
|
}
|
|
|
|
// Send password update to each connected member
|
|
for peerID in connectedMembersWithNoise {
|
|
meshService.sendChannelPasswordUpdate(newPassword, channel: channel, newCommitment: newCommitment, to: peerID)
|
|
}
|
|
|
|
if !connectedMembersWithNoise.isEmpty {
|
|
// Sent password update to connected members with Noise sessions
|
|
|
|
// Update the success message to indicate distribution
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
let distributeMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "password update sent to \(connectedMembersWithNoise.count) online members via secure channel.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
self?.messages.append(distributeMsg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute SHA256 hash of the derived key for public verification
|
|
internal func computeKeyCommitment(for key: SymmetricKey) -> String {
|
|
let keyData = key.withUnsafeBytes { Data($0) }
|
|
let hash = SHA256.hash(data: keyData)
|
|
return hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
internal func deriveChannelKey(from password: String, channelName: String) -> SymmetricKey {
|
|
// Get creator fingerprint for this channel
|
|
let creatorFingerprint = channelCreators[channelName]
|
|
|
|
// Use PBKDF2 with channel name + creator fingerprint as salt
|
|
var saltComponents = "bitchat-channel-\(channelName)"
|
|
if let fingerprint = creatorFingerprint {
|
|
saltComponents += "-\(fingerprint)"
|
|
}
|
|
let salt = saltComponents.data(using: .utf8)!
|
|
|
|
// Increased iterations for better security (OWASP recommends 210,000 for PBKDF2-SHA256)
|
|
let keyData = pbkdf2(password: password, salt: salt, iterations: 210000, keyLength: 32)
|
|
return SymmetricKey(data: keyData)
|
|
}
|
|
|
|
private func pbkdf2(password: String, salt: Data, iterations: Int, keyLength: Int) -> Data {
|
|
var derivedKey = Data(count: keyLength)
|
|
let passwordData = password.data(using: .utf8)!
|
|
|
|
_ = derivedKey.withUnsafeMutableBytes { derivedKeyBytes in
|
|
salt.withUnsafeBytes { saltBytes in
|
|
passwordData.withUnsafeBytes { passwordBytes in
|
|
CCKeyDerivationPBKDF(
|
|
CCPBKDFAlgorithm(kCCPBKDF2),
|
|
passwordBytes.baseAddress, passwordData.count,
|
|
saltBytes.baseAddress, salt.count,
|
|
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
|
UInt32(iterations),
|
|
derivedKeyBytes.baseAddress, keyLength
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return derivedKey
|
|
}
|
|
|
|
func switchToChannel(_ channel: String?) {
|
|
// Check if channel needs password
|
|
if let channel = channel, passwordProtectedChannels.contains(channel) && channelKeys[channel] == nil {
|
|
// Need password, show prompt instead
|
|
passwordPromptChannel = channel
|
|
showPasswordPrompt = true
|
|
return
|
|
}
|
|
|
|
currentChannel = channel
|
|
selectedPrivateChatPeer = nil // Exit private chat
|
|
|
|
// Clear unread count for this channel
|
|
if let channel = channel {
|
|
unreadChannelMessages[channel] = 0
|
|
}
|
|
}
|
|
|
|
private func sendChannelKeyVerificationRequest(for channel: String) {
|
|
|
|
// Get channel members who might have the key
|
|
let members = getChannelMembers(channel)
|
|
|
|
// Filter to only connected members
|
|
let connectedMembers = members.filter { connectedPeers.contains($0) && $0 != meshService.myPeerID }
|
|
|
|
guard !connectedMembers.isEmpty else {
|
|
channelVerificationStatus[channel] = .unverified
|
|
return
|
|
}
|
|
|
|
// Compute our key commitment
|
|
guard let key = channelKeys[channel] else { return }
|
|
let commitment = computeKeyCommitment(for: key)
|
|
|
|
// Create verification request
|
|
let request = ChannelKeyVerifyRequest(
|
|
channel: channel,
|
|
requesterID: meshService.myPeerID,
|
|
keyCommitment: commitment
|
|
)
|
|
|
|
// Send to all connected channel members
|
|
meshService.sendChannelKeyVerifyRequest(request, to: connectedMembers)
|
|
|
|
// Update verification status
|
|
channelVerificationStatus[channel] = .verifying
|
|
// Set verification status to verifying
|
|
|
|
// Set timeout for verification
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Check if still verifying
|
|
if self.channelVerificationStatus[channel] == .verifying {
|
|
// No response received, assume unverified
|
|
// Verification timeout
|
|
self.channelVerificationStatus[channel] = .unverified
|
|
|
|
let timeoutMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "no response from channel members. password may be correct but cannot verify at this time.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
self.messages.append(timeoutMsg)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
private func getChannelMembers(_ channel: String) -> [String] {
|
|
// Return all peers who have sent messages to this channel
|
|
return Array(channelMembers[channel] ?? [])
|
|
}
|
|
|
|
// Check if current user owns the channel (handles both legacy peer ID and new fingerprint)
|
|
func isChannelOwner(_ channel: String) -> Bool {
|
|
guard let creator = channelCreators[channel] else {
|
|
return false
|
|
}
|
|
|
|
let myFingerprint = getMyFingerprint()
|
|
|
|
|
|
// Only check against persistent fingerprint
|
|
if myFingerprint != "Unknown" && creator == myFingerprint {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Migrate channel ownership from ephemeral peer IDs to persistent fingerprints
|
|
private func cleanupLegacyChannelOwnership() {
|
|
// Simple cleanup: only keep channels with fingerprint ownership
|
|
let myFingerprint = getMyFingerprint()
|
|
guard myFingerprint != "Unknown" else { return }
|
|
|
|
var cleaned = false
|
|
for (channel, creator) in channelCreators {
|
|
// Remove any channel with 8-char creator ID (legacy)
|
|
if creator.count == 8 {
|
|
// Removing legacy ownership
|
|
channelCreators.removeValue(forKey: channel)
|
|
cleaned = true
|
|
}
|
|
}
|
|
|
|
if cleaned {
|
|
saveChannelData()
|
|
}
|
|
}
|
|
|
|
|
|
func getChannelMessages(_ channel: String) -> [BitchatMessage] {
|
|
return channelMessages[channel] ?? []
|
|
}
|
|
|
|
func parseChannels(from content: String) -> Set<String> {
|
|
let pattern = "#([a-zA-Z0-9_]+)"
|
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
|
|
var channels = Set<String>()
|
|
for match in matches {
|
|
if let range = Range(match.range(at: 0), in: content) {
|
|
let channel = String(content[range])
|
|
channels.insert(channel)
|
|
}
|
|
}
|
|
|
|
return channels
|
|
}
|
|
|
|
func toggleFavorite(peerID: String) {
|
|
// First try to get fingerprint from mesh service (supports peer ID rotation)
|
|
var fingerprint: String? = meshService.getFingerprint(for: peerID)
|
|
|
|
// Fallback to local mapping if not found in mesh service
|
|
if fingerprint == nil {
|
|
fingerprint = peerIDToPublicKeyFingerprint[peerID]
|
|
}
|
|
|
|
guard let fp = fingerprint else {
|
|
return
|
|
}
|
|
|
|
let isFavorite = SecureIdentityStateManager.shared.isFavorite(fingerprint: fp)
|
|
SecureIdentityStateManager.shared.setFavorite(fp, isFavorite: !isFavorite)
|
|
|
|
// Update local set for UI
|
|
if isFavorite {
|
|
favoritePeers.remove(fp)
|
|
} else {
|
|
favoritePeers.insert(fp)
|
|
}
|
|
}
|
|
|
|
func isFavorite(peerID: String) -> Bool {
|
|
// First try to get fingerprint from mesh service (supports peer ID rotation)
|
|
var fingerprint: String? = meshService.getFingerprint(for: peerID)
|
|
|
|
// Fallback to local mapping if not found in mesh service
|
|
if fingerprint == nil {
|
|
fingerprint = peerIDToPublicKeyFingerprint[peerID]
|
|
}
|
|
|
|
guard let fp = fingerprint else {
|
|
return false
|
|
}
|
|
|
|
return SecureIdentityStateManager.shared.isFavorite(fingerprint: fp)
|
|
}
|
|
|
|
// Called when we receive a peer's public key
|
|
func registerPeerPublicKey(peerID: String, publicKeyData: Data) {
|
|
// Create a fingerprint from the public key (full SHA256, not truncated)
|
|
let fingerprintStr = SHA256.hash(data: publicKeyData)
|
|
.compactMap { String(format: "%02x", $0) }
|
|
.joined()
|
|
|
|
// Only register if not already registered
|
|
if peerIDToPublicKeyFingerprint[peerID] != fingerprintStr {
|
|
peerIDToPublicKeyFingerprint[peerID] = fingerprintStr
|
|
}
|
|
|
|
// Update identity state manager with handshake completion
|
|
SecureIdentityStateManager.shared.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprintStr))
|
|
|
|
// Update encryption status now that we have the fingerprint
|
|
updateEncryptionStatus(for: peerID)
|
|
|
|
// Check if we have a claimed nickname for this peer
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
if let nickname = peerNicknames[peerID], nickname != "Unknown" && nickname != "anon\(peerID.prefix(4))" {
|
|
// Update or create social identity with the claimed nickname
|
|
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) {
|
|
identity.claimedNickname = nickname
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
|
} else {
|
|
let newIdentity = SocialIdentity(
|
|
fingerprint: fingerprintStr,
|
|
localPetname: nil,
|
|
claimedNickname: nickname,
|
|
trustLevel: .casual,
|
|
isFavorite: false,
|
|
isBlocked: false,
|
|
notes: nil
|
|
)
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(newIdentity)
|
|
}
|
|
}
|
|
|
|
// Check if this peer is the one we're in a private chat with
|
|
updatePrivateChatPeerIfNeeded()
|
|
}
|
|
|
|
private func isPeerBlocked(_ peerID: String) -> Bool {
|
|
// Check if we have the public key fingerprint for this peer
|
|
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
|
|
return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint)
|
|
}
|
|
|
|
// Try to get fingerprint from mesh service
|
|
if let fingerprint = meshService.getPeerFingerprint(peerID) {
|
|
return SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Helper method to find current peer ID for a fingerprint
|
|
private func getCurrentPeerIDForFingerprint(_ fingerprint: String) -> String? {
|
|
// Search through all connected peers to find the one with matching fingerprint
|
|
for peerID in connectedPeers {
|
|
if let mappedFingerprint = peerIDToPublicKeyFingerprint[peerID],
|
|
mappedFingerprint == fingerprint {
|
|
return peerID
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper method to update selectedPrivateChatPeer if fingerprint matches
|
|
private func updatePrivateChatPeerIfNeeded() {
|
|
guard let chatFingerprint = selectedPrivateChatFingerprint else { return }
|
|
|
|
// Find current peer ID for the fingerprint
|
|
if let currentPeerID = getCurrentPeerIDForFingerprint(chatFingerprint) {
|
|
// Update the selected peer if it's different
|
|
if let oldPeerID = selectedPrivateChatPeer, oldPeerID != currentPeerID {
|
|
// Migrate messages from old peer ID to new peer ID
|
|
if let oldMessages = privateChats[oldPeerID] {
|
|
if privateChats[currentPeerID] == nil {
|
|
privateChats[currentPeerID] = []
|
|
}
|
|
privateChats[currentPeerID]?.append(contentsOf: oldMessages)
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
}
|
|
|
|
// Migrate unread status
|
|
if unreadPrivateMessages.contains(oldPeerID) {
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
unreadPrivateMessages.insert(currentPeerID)
|
|
}
|
|
|
|
selectedPrivateChatPeer = currentPeerID
|
|
} else if selectedPrivateChatPeer == nil {
|
|
// Just set the peer ID if we don't have one
|
|
selectedPrivateChatPeer = currentPeerID
|
|
}
|
|
|
|
// Clear unread messages for the current peer ID
|
|
unreadPrivateMessages.remove(currentPeerID)
|
|
}
|
|
}
|
|
|
|
func sendMessage(_ content: String) {
|
|
guard !content.isEmpty else { return }
|
|
|
|
// Check for commands
|
|
if content.hasPrefix("/") {
|
|
handleCommand(content)
|
|
return
|
|
}
|
|
|
|
if selectedPrivateChatPeer != nil {
|
|
// Update peer ID in case it changed due to reconnection
|
|
updatePrivateChatPeerIfNeeded()
|
|
|
|
if let selectedPeer = selectedPrivateChatPeer {
|
|
// Send as private message
|
|
sendPrivateMessage(content, to: selectedPeer)
|
|
} else {
|
|
}
|
|
} else {
|
|
// Parse mentions and channels from the content
|
|
let mentions = parseMentions(from: content)
|
|
let channels = parseChannels(from: content)
|
|
|
|
// Auto-join any channels mentioned in the message
|
|
for channel in channels {
|
|
if !joinedChannels.contains(channel) {
|
|
let _ = joinChannel(channel)
|
|
}
|
|
}
|
|
|
|
// Determine which channel this message belongs to
|
|
let messageChannel = currentChannel // Use current channel if we're in one
|
|
|
|
// Add message to local display
|
|
let message = BitchatMessage(
|
|
sender: nickname,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: meshService.myPeerID,
|
|
mentions: mentions.isEmpty ? nil : mentions,
|
|
channel: messageChannel
|
|
)
|
|
|
|
if let channel = messageChannel {
|
|
// Add to channel messages
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
channelMessages[channel]?.append(message)
|
|
|
|
// Track ourselves as a channel member
|
|
if channelMembers[channel] == nil {
|
|
channelMembers[channel] = Set()
|
|
}
|
|
channelMembers[channel]?.insert(meshService.myPeerID)
|
|
} else {
|
|
// Add to main messages
|
|
messages.append(message)
|
|
}
|
|
|
|
// Only auto-join channels if we're sending TO that channel
|
|
if let messageChannel = messageChannel {
|
|
if !joinedChannels.contains(messageChannel) {
|
|
let _ = joinChannel(messageChannel)
|
|
}
|
|
}
|
|
|
|
// Check if channel is password protected and encrypt if needed
|
|
if let channel = messageChannel, channelKeys[channel] != nil {
|
|
// Send encrypted channel message
|
|
meshService.sendEncryptedChannelMessage(content, mentions: mentions, channel: channel, channelKey: channelKeys[channel]!)
|
|
} else {
|
|
// Send via mesh with mentions and channel (unencrypted)
|
|
meshService.sendMessage(content, mentions: mentions, channel: messageChannel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func sendPrivateMessage(_ content: String, to peerID: String) {
|
|
guard !content.isEmpty else { return }
|
|
guard let recipientNickname = meshService.getPeerNicknames()[peerID] else {
|
|
return
|
|
}
|
|
|
|
// Check if the recipient is blocked
|
|
if isPeerBlocked(peerID) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot send message to \(recipientNickname): user is blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
return
|
|
}
|
|
|
|
// IMPORTANT: When sending a message, it means we're viewing this chat
|
|
// Send read receipts for any delivered messages from this peer
|
|
markPrivateMessagesAsRead(from: peerID)
|
|
|
|
// Create the message locally
|
|
let message = BitchatMessage(
|
|
sender: nickname,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: recipientNickname,
|
|
senderPeerID: meshService.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
|
|
// Add to our private chat history
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
privateChats[peerID]?.append(message)
|
|
|
|
// Track the message for delivery confirmation
|
|
let isFavorite = isFavorite(peerID: peerID)
|
|
DeliveryTracker.shared.trackMessage(message, recipientID: peerID, recipientNickname: recipientNickname, isFavorite: isFavorite)
|
|
|
|
// Trigger UI update
|
|
objectWillChange.send()
|
|
|
|
// Send via mesh with the same message ID
|
|
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: message.id)
|
|
}
|
|
|
|
func startPrivateChat(with peerID: String) {
|
|
let peerNickname = meshService.getPeerNicknames()[peerID] ?? "unknown"
|
|
|
|
// Check if the peer is blocked
|
|
if isPeerBlocked(peerID) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot start chat with \(peerNickname): user is blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
return
|
|
}
|
|
|
|
selectedPrivateChatPeer = peerID
|
|
// Also track by fingerprint for persistence across reconnections
|
|
selectedPrivateChatFingerprint = peerIDToPublicKeyFingerprint[peerID]
|
|
unreadPrivateMessages.remove(peerID)
|
|
|
|
// Check if we need to migrate messages from an old peer ID
|
|
// This happens when peer IDs change between sessions
|
|
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
|
|
|
|
// Look for messages from this nickname under other peer IDs
|
|
var migratedMessages: [BitchatMessage] = []
|
|
var oldPeerIDsToRemove: [String] = []
|
|
|
|
for (oldPeerID, messages) in privateChats {
|
|
if oldPeerID != peerID {
|
|
// Check if any messages in this chat are from the peer's nickname
|
|
// Check if this chat contains messages with this peer
|
|
let messagesWithPeer = messages.filter { msg in
|
|
// Message is FROM the peer to us
|
|
(msg.sender == peerNickname && msg.sender != nickname) ||
|
|
// OR message is FROM us TO the peer
|
|
(msg.sender == nickname && (msg.recipientNickname == peerNickname ||
|
|
// Also check if this was a private message in a chat that only has us and one other person
|
|
(msg.isPrivate && messages.allSatisfy { m in
|
|
m.sender == nickname || m.sender == peerNickname
|
|
})))
|
|
}
|
|
|
|
if !messagesWithPeer.isEmpty {
|
|
|
|
// Check if ALL messages in this chat are between us and this peer
|
|
let allMessagesAreWithPeer = messages.allSatisfy { msg in
|
|
(msg.sender == peerNickname || msg.sender == nickname) &&
|
|
(msg.recipientNickname == nil || msg.recipientNickname == peerNickname || msg.recipientNickname == nickname)
|
|
}
|
|
|
|
if allMessagesAreWithPeer {
|
|
// This entire chat history belongs to this peer, migrate it all
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove old peer ID entries that were fully migrated
|
|
for oldPeerID in oldPeerIDsToRemove {
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
}
|
|
|
|
// Initialize chat history with migrated messages if any
|
|
if !migratedMessages.isEmpty {
|
|
privateChats[peerID] = migratedMessages.sorted { $0.timestamp < $1.timestamp }
|
|
} else {
|
|
privateChats[peerID] = []
|
|
}
|
|
}
|
|
|
|
_ = privateChats[peerID] ?? []
|
|
|
|
// Send read receipts for unread messages from this peer
|
|
// Add a small delay to ensure UI has updated
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
// Also try immediately in case messages are already there
|
|
markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
|
|
func endPrivateChat() {
|
|
selectedPrivateChatPeer = nil
|
|
selectedPrivateChatFingerprint = nil
|
|
}
|
|
|
|
@objc private func appDidBecomeActive() {
|
|
// When app becomes active, send read receipts for visible private chat
|
|
if let peerID = selectedPrivateChatPeer {
|
|
// Try immediately
|
|
self.markPrivateMessagesAsRead(from: peerID)
|
|
// And again with a delay
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
self.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func userDidTakeScreenshot() {
|
|
// Send screenshot notification based on current context
|
|
let screenshotMessage = "* \(nickname) took a screenshot *"
|
|
|
|
if let peerID = selectedPrivateChatPeer {
|
|
// In private chat - send to the other person
|
|
if let peerNickname = meshService.getPeerNicknames()[peerID] {
|
|
// Send the message directly without going through sendPrivateMessage to avoid local echo
|
|
meshService.sendPrivateMessage(screenshotMessage, to: peerID, recipientNickname: peerNickname)
|
|
}
|
|
|
|
// Show local notification immediately as system message
|
|
let localNotification = BitchatMessage(
|
|
sender: "system",
|
|
content: "you took a screenshot",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: meshService.getPeerNicknames()[peerID],
|
|
senderPeerID: meshService.myPeerID
|
|
)
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
privateChats[peerID]?.append(localNotification)
|
|
|
|
} else if let channel = currentChannel {
|
|
// In a channel - send to channel
|
|
// Check if channel is password protected and encrypt if needed
|
|
if let channelKey = channelKeys[channel] {
|
|
meshService.sendEncryptedChannelMessage(screenshotMessage, mentions: [], channel: channel, channelKey: channelKey)
|
|
} else {
|
|
meshService.sendMessage(screenshotMessage, mentions: [], channel: channel)
|
|
}
|
|
|
|
// Show local notification immediately as system message
|
|
let localNotification = BitchatMessage(
|
|
sender: "system",
|
|
content: "you took a screenshot",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
channel: channel
|
|
)
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
channelMessages[channel]?.append(localNotification)
|
|
|
|
} else {
|
|
// In public chat - send to everyone
|
|
meshService.sendMessage(screenshotMessage, mentions: [], channel: nil)
|
|
|
|
// Show local notification immediately as system message
|
|
let localNotification = BitchatMessage(
|
|
sender: "system",
|
|
content: "you took a screenshot",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(localNotification)
|
|
}
|
|
}
|
|
|
|
@objc private func appWillResignActive() {
|
|
// Save all channel data
|
|
saveChannelData()
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
@objc func applicationWillTerminate() {
|
|
|
|
// Verify identity key is still there
|
|
_ = KeychainManager.shared.verifyIdentityKeyExists()
|
|
|
|
// Save all channel data
|
|
saveChannelData()
|
|
userDefaults.synchronize()
|
|
|
|
// Verify identity key after save
|
|
_ = KeychainManager.shared.verifyIdentityKeyExists()
|
|
}
|
|
|
|
@objc private func appWillTerminate() {
|
|
// Save all channel data
|
|
saveChannelData()
|
|
userDefaults.synchronize()
|
|
}
|
|
|
|
func markPrivateMessagesAsRead(from peerID: String) {
|
|
// Get the nickname for this peer
|
|
let peerNickname = meshService.getPeerNicknames()[peerID] ?? ""
|
|
|
|
// First ensure we have the latest messages (in case of migration)
|
|
if let messages = privateChats[peerID], !messages.isEmpty {
|
|
} else {
|
|
|
|
// Look through ALL private chats to find messages from this nickname
|
|
for (_, chatMessages) in privateChats {
|
|
let relevantMessages = chatMessages.filter { msg in
|
|
msg.sender == peerNickname && msg.sender != nickname
|
|
}
|
|
if !relevantMessages.isEmpty {
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let messages = privateChats[peerID], !messages.isEmpty else {
|
|
return
|
|
}
|
|
|
|
|
|
// Find messages from the peer that haven't been read yet
|
|
var readReceiptsSent = 0
|
|
for (_, message) in messages.enumerated() {
|
|
// Only send read receipts for messages from the other peer (not our own)
|
|
// Check multiple conditions to ensure we catch all messages from the peer
|
|
let isOurMessage = message.sender == nickname
|
|
let isFromPeerByNickname = !peerNickname.isEmpty && message.sender == peerNickname
|
|
let isFromPeerByID = message.senderPeerID == peerID
|
|
let isPrivateToUs = message.isPrivate && message.recipientNickname == nickname
|
|
|
|
// This is a message FROM the peer if it's not from us AND (matches nickname OR peer ID OR is private to us)
|
|
let isFromPeer = !isOurMessage && (isFromPeerByNickname || isFromPeerByID || isPrivateToUs)
|
|
|
|
if message.id == message.id { // Always true, for debugging
|
|
}
|
|
|
|
if isFromPeer {
|
|
if let status = message.deliveryStatus {
|
|
switch status {
|
|
case .sent, .delivered:
|
|
// Create and send read receipt for sent or delivered messages
|
|
// Check if we've already sent a receipt for this message
|
|
if !sentReadReceipts.contains(message.id) {
|
|
// Send to the CURRENT peer ID, not the old senderPeerID which may have changed
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
meshService.sendReadReceipt(receipt, to: peerID)
|
|
sentReadReceipts.insert(message.id)
|
|
readReceiptsSent += 1
|
|
} else {
|
|
}
|
|
case .read:
|
|
// Already read, no need to send another receipt
|
|
break
|
|
default:
|
|
// Message not yet delivered, can't mark as read
|
|
break
|
|
}
|
|
} else {
|
|
// No delivery status - this might be an older message
|
|
// Send read receipt anyway for backwards compatibility
|
|
if !sentReadReceipts.contains(message.id) {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
meshService.sendReadReceipt(receipt, to: peerID)
|
|
sentReadReceipts.insert(message.id)
|
|
readReceiptsSent += 1
|
|
} else {
|
|
}
|
|
}
|
|
} else {
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
|
|
let messages = privateChats[peerID] ?? []
|
|
if !messages.isEmpty {
|
|
}
|
|
return messages
|
|
}
|
|
|
|
func getPeerIDForNickname(_ nickname: String) -> String? {
|
|
let nicknames = meshService.getPeerNicknames()
|
|
return nicknames.first(where: { $0.value == nickname })?.key
|
|
}
|
|
|
|
|
|
// PANIC: Emergency data clearing for activist safety
|
|
func panicClearAllData() {
|
|
// Clear all messages
|
|
messages.removeAll()
|
|
privateChats.removeAll()
|
|
unreadPrivateMessages.removeAll()
|
|
|
|
// Clear all channel data
|
|
joinedChannels.removeAll()
|
|
currentChannel = nil
|
|
channelMessages.removeAll()
|
|
unreadChannelMessages.removeAll()
|
|
channelMembers.removeAll()
|
|
channelPasswords.removeAll()
|
|
channelKeys.removeAll()
|
|
passwordProtectedChannels.removeAll()
|
|
channelCreators.removeAll()
|
|
channelKeyCommitments.removeAll()
|
|
showPasswordPrompt = false
|
|
passwordPromptChannel = nil
|
|
|
|
// First run aggressive cleanup to get rid of all legacy items
|
|
_ = KeychainManager.shared.aggressiveCleanupLegacyItems()
|
|
|
|
// Then delete all current keychain data
|
|
_ = KeychainManager.shared.deleteAllKeychainData()
|
|
|
|
// Clear UserDefaults identity fallbacks
|
|
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
|
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
|
|
|
|
// Clear verified fingerprints
|
|
verifiedFingerprints.removeAll()
|
|
// Verified fingerprints are cleared when identity data is cleared below
|
|
|
|
// Clear message retry queue
|
|
MessageRetryService.shared.clearRetryQueue()
|
|
|
|
// Clear persisted channel data from UserDefaults
|
|
userDefaults.removeObject(forKey: joinedChannelsKey)
|
|
userDefaults.removeObject(forKey: passwordProtectedChannelsKey)
|
|
userDefaults.removeObject(forKey: channelCreatorsKey)
|
|
userDefaults.removeObject(forKey: channelKeyCommitmentsKey)
|
|
|
|
// Reset nickname to anonymous
|
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
|
saveNickname()
|
|
|
|
// Clear favorites
|
|
favoritePeers.removeAll()
|
|
peerIDToPublicKeyFingerprint.removeAll()
|
|
|
|
// Clear identity data from secure storage
|
|
SecureIdentityStateManager.shared.clearAllIdentityData()
|
|
|
|
// Clear autocomplete state
|
|
autocompleteSuggestions.removeAll()
|
|
showAutocomplete = false
|
|
autocompleteRange = nil
|
|
selectedAutocompleteIndex = 0
|
|
|
|
// Clear selected private chat
|
|
selectedPrivateChatPeer = nil
|
|
selectedPrivateChatFingerprint = nil
|
|
|
|
// Clear read receipt tracking
|
|
sentReadReceipts.removeAll()
|
|
|
|
// Disconnect from all peers and clear persistent identity
|
|
// This will force creation of a new identity (new fingerprint) on next launch
|
|
meshService.emergencyDisconnectAll()
|
|
|
|
// Force immediate UserDefaults synchronization
|
|
userDefaults.synchronize()
|
|
|
|
// Force UI update
|
|
objectWillChange.send()
|
|
|
|
}
|
|
|
|
|
|
|
|
func formatTimestamp(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss"
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
func getRSSIColor(rssi: Int, colorScheme: ColorScheme) -> Color {
|
|
let isDark = colorScheme == .dark
|
|
// RSSI typically ranges from -30 (excellent) to -90 (poor)
|
|
// We'll map this to colors from green (strong) to red (weak)
|
|
|
|
if rssi >= -50 {
|
|
// Excellent signal: bright green
|
|
return isDark ? Color(red: 0.0, green: 1.0, blue: 0.0) : Color(red: 0.0, green: 0.7, blue: 0.0)
|
|
} else if rssi >= -60 {
|
|
// Good signal: green-yellow
|
|
return isDark ? Color(red: 0.5, green: 1.0, blue: 0.0) : Color(red: 0.3, green: 0.7, blue: 0.0)
|
|
} else if rssi >= -70 {
|
|
// Fair signal: yellow
|
|
return isDark ? Color(red: 1.0, green: 1.0, blue: 0.0) : Color(red: 0.7, green: 0.7, blue: 0.0)
|
|
} else if rssi >= -80 {
|
|
// Weak signal: orange
|
|
return isDark ? Color(red: 1.0, green: 0.6, blue: 0.0) : Color(red: 0.8, green: 0.4, blue: 0.0)
|
|
} else {
|
|
// Poor signal: red
|
|
return isDark ? Color(red: 1.0, green: 0.2, blue: 0.2) : Color(red: 0.8, green: 0.0, blue: 0.0)
|
|
}
|
|
}
|
|
|
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
|
// Find @ symbol before cursor
|
|
let beforeCursor = String(text.prefix(cursorPosition))
|
|
|
|
// Look for @ pattern
|
|
let pattern = "@([a-zA-Z0-9_]*)$"
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
|
let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
return
|
|
}
|
|
|
|
// Extract the partial nickname
|
|
let partialRange = match.range(at: 1)
|
|
guard let range = Range(partialRange, in: beforeCursor) else {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
return
|
|
}
|
|
|
|
let partial = String(beforeCursor[range]).lowercased()
|
|
|
|
// Get all available nicknames (excluding self)
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
let allNicknames = Array(peerNicknames.values)
|
|
|
|
// Filter suggestions
|
|
let suggestions = allNicknames.filter { nick in
|
|
nick.lowercased().hasPrefix(partial)
|
|
}.sorted()
|
|
|
|
if !suggestions.isEmpty {
|
|
autocompleteSuggestions = suggestions
|
|
showAutocomplete = true
|
|
autocompleteRange = match.range(at: 0) // Store full @mention range
|
|
selectedAutocompleteIndex = 0
|
|
} else {
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
selectedAutocompleteIndex = 0
|
|
}
|
|
}
|
|
|
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
|
guard let range = autocompleteRange else { return text.count }
|
|
|
|
// Replace the @partial with @nickname
|
|
let nsText = text as NSString
|
|
let newText = nsText.replacingCharacters(in: range, with: "@\(nickname) ")
|
|
text = newText
|
|
|
|
// Hide autocomplete
|
|
showAutocomplete = false
|
|
autocompleteSuggestions = []
|
|
autocompleteRange = nil
|
|
selectedAutocompleteIndex = 0
|
|
|
|
// Return new cursor position (after the space)
|
|
return range.location + nickname.count + 2
|
|
}
|
|
|
|
func getSenderColor(for message: BitchatMessage, colorScheme: ColorScheme) -> Color {
|
|
let isDark = colorScheme == .dark
|
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
|
|
if message.sender == nickname {
|
|
return primaryColor
|
|
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
|
|
let rssi = meshService.getPeerRSSI()[peerID] {
|
|
return getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
|
|
} else {
|
|
return primaryColor.opacity(0.9)
|
|
}
|
|
}
|
|
|
|
|
|
func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
|
let isDark = colorScheme == .dark
|
|
let contentText = message.content
|
|
var processedContent = AttributedString()
|
|
|
|
// Regular expressions for mentions and hashtags
|
|
let mentionPattern = "@([a-zA-Z0-9_]+)"
|
|
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
|
|
|
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
|
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
|
|
|
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
|
|
// Combine and sort all matches
|
|
var allMatches: [(range: NSRange, type: String)] = []
|
|
for match in mentionMatches {
|
|
allMatches.append((match.range(at: 0), "mention"))
|
|
}
|
|
for match in hashtagMatches {
|
|
allMatches.append((match.range(at: 0), "hashtag"))
|
|
}
|
|
allMatches.sort { $0.range.location < $1.range.location }
|
|
|
|
var lastEndIndex = contentText.startIndex
|
|
|
|
for (matchRange, matchType) in allMatches {
|
|
// Add text before the match
|
|
if let range = Range(matchRange, in: contentText) {
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
if !beforeText.isEmpty {
|
|
var normalStyle = AttributeContainer()
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
}
|
|
|
|
// Add the match with appropriate styling
|
|
let matchText = String(contentText[range])
|
|
var matchStyle = AttributeContainer()
|
|
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
|
|
|
if matchType == "mention" {
|
|
matchStyle.foregroundColor = Color.orange
|
|
} else {
|
|
// Hashtag
|
|
matchStyle.foregroundColor = Color.blue
|
|
matchStyle.underlineStyle = .single
|
|
}
|
|
|
|
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
|
|
|
lastEndIndex = range.upperBound
|
|
}
|
|
}
|
|
|
|
// Add any remaining text
|
|
if lastEndIndex < contentText.endIndex {
|
|
let remainingText = String(contentText[lastEndIndex...])
|
|
var normalStyle = AttributeContainer()
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
|
}
|
|
|
|
return processedContent
|
|
}
|
|
|
|
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
|
var result = AttributedString()
|
|
|
|
let isDark = colorScheme == .dark
|
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
let secondaryColor = primaryColor.opacity(0.7)
|
|
|
|
// Timestamp
|
|
let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
|
|
timestampStyle.font = .system(size: 12, design: .monospaced)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
|
|
if message.sender != "system" {
|
|
// Sender
|
|
let sender = AttributedString("<@\(message.sender)> ")
|
|
var senderStyle = AttributeContainer()
|
|
|
|
// Get sender color
|
|
let senderColor: Color
|
|
if message.sender == nickname {
|
|
senderColor = primaryColor
|
|
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
|
|
let rssi = meshService.getPeerRSSI()[peerID] {
|
|
senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
|
|
} else {
|
|
senderColor = primaryColor.opacity(0.9)
|
|
}
|
|
|
|
senderStyle.foregroundColor = senderColor
|
|
senderStyle.font = .system(size: 14, weight: .medium, design: .monospaced)
|
|
result.append(sender.mergingAttributes(senderStyle))
|
|
|
|
// Process content with hashtags, mentions, and markdown links
|
|
var content = message.content
|
|
|
|
// First, check if content starts with 👇 followed by markdown link
|
|
if content.hasPrefix("👇 [") {
|
|
// This is a URL share - remove everything after the emoji
|
|
if let linkStart = content.firstIndex(of: "[") {
|
|
let indexBeforeLink = content.index(before: linkStart)
|
|
content = String(content[..<indexBeforeLink])
|
|
}
|
|
} else {
|
|
// Handle normal markdown links
|
|
let markdownLinkPattern = #"\[([^\]]+)\]\(([^)]+)\)"#
|
|
if let markdownRegex = try? NSRegularExpression(pattern: markdownLinkPattern, options: []) {
|
|
let markdownMatches = markdownRegex.matches(in: content, options: [], range: NSRange(location: 0, length: content.count))
|
|
|
|
// Process matches in reverse order to maintain string indices
|
|
for match in markdownMatches.reversed() {
|
|
if let fullRange = Range(match.range, in: content),
|
|
let titleRange = Range(match.range(at: 1), in: content) {
|
|
// Normal markdown link - replace with just the title
|
|
let linkTitle = String(content[titleRange])
|
|
content.replaceSubrange(fullRange, with: linkTitle)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
|
let mentionPattern = "@([a-zA-Z0-9_]+)"
|
|
|
|
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
|
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
|
|
|
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
|
|
// Combine and sort matches
|
|
var allMatches: [(range: NSRange, type: String)] = []
|
|
for match in hashtagMatches {
|
|
allMatches.append((match.range(at: 0), "hashtag"))
|
|
}
|
|
for match in mentionMatches {
|
|
allMatches.append((match.range(at: 0), "mention"))
|
|
}
|
|
allMatches.sort { $0.range.location < $1.range.location }
|
|
|
|
// Build content with styling
|
|
var lastEnd = content.startIndex
|
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
|
|
|
for (range, type) in allMatches {
|
|
// Add text before match
|
|
if let nsRange = Range(range, in: content) {
|
|
let beforeText = String(content[lastEnd..<nsRange.lowerBound])
|
|
if !beforeText.isEmpty {
|
|
var beforeStyle = AttributeContainer()
|
|
beforeStyle.foregroundColor = primaryColor
|
|
beforeStyle.font = .system(size: 14, design: .monospaced)
|
|
if isMentioned {
|
|
beforeStyle.font = beforeStyle.font?.bold()
|
|
}
|
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
|
}
|
|
|
|
// Add styled match
|
|
let matchText = String(content[nsRange])
|
|
var matchStyle = AttributeContainer()
|
|
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
|
|
|
if type == "hashtag" {
|
|
matchStyle.foregroundColor = Color.blue
|
|
matchStyle.underlineStyle = .single
|
|
} else if type == "mention" {
|
|
matchStyle.foregroundColor = Color.orange
|
|
}
|
|
|
|
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
|
lastEnd = nsRange.upperBound
|
|
}
|
|
}
|
|
|
|
// Add remaining text
|
|
if lastEnd < content.endIndex {
|
|
let remainingText = String(content[lastEnd...])
|
|
var remainingStyle = AttributeContainer()
|
|
remainingStyle.foregroundColor = primaryColor
|
|
remainingStyle.font = .system(size: 14, design: .monospaced)
|
|
if isMentioned {
|
|
remainingStyle.font = remainingStyle.font?.bold()
|
|
}
|
|
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
|
}
|
|
} else {
|
|
// System message
|
|
var contentStyle = AttributeContainer()
|
|
// Check for welcome message
|
|
if message.content.contains("get people around you to download bitchat") {
|
|
contentStyle.foregroundColor = Color.blue
|
|
} else {
|
|
contentStyle.foregroundColor = Color.gray
|
|
}
|
|
let content = AttributedString("* \(message.content) *")
|
|
contentStyle.font = .system(size: 12, design: .monospaced).italic()
|
|
result.append(content.mergingAttributes(contentStyle))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
|
var result = AttributedString()
|
|
|
|
let isDark = colorScheme == .dark
|
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
let secondaryColor = primaryColor.opacity(0.7)
|
|
|
|
let timestamp = AttributedString("[\(formatTimestamp(message.timestamp))] ")
|
|
var timestampStyle = AttributeContainer()
|
|
timestampStyle.foregroundColor = message.sender == "system" ? Color.gray : secondaryColor
|
|
timestampStyle.font = .system(size: 12, design: .monospaced)
|
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
|
|
if message.sender == "system" {
|
|
let content = AttributedString("* \(message.content) *")
|
|
var contentStyle = AttributeContainer()
|
|
contentStyle.foregroundColor = Color.gray
|
|
contentStyle.font = .system(size: 12, design: .monospaced).italic()
|
|
result.append(content.mergingAttributes(contentStyle))
|
|
} else {
|
|
let sender = AttributedString("<\(message.sender)> ")
|
|
var senderStyle = AttributeContainer()
|
|
|
|
// Get RSSI-based color
|
|
let senderColor: Color
|
|
if message.sender == nickname {
|
|
senderColor = primaryColor
|
|
} else if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender),
|
|
let rssi = meshService.getPeerRSSI()[peerID] {
|
|
senderColor = getRSSIColor(rssi: rssi.intValue, colorScheme: colorScheme)
|
|
} else {
|
|
senderColor = primaryColor.opacity(0.9)
|
|
}
|
|
|
|
senderStyle.foregroundColor = senderColor
|
|
senderStyle.font = .system(size: 12, weight: .medium, design: .monospaced)
|
|
result.append(sender.mergingAttributes(senderStyle))
|
|
|
|
|
|
// Process content to highlight mentions
|
|
let contentText = message.content
|
|
var processedContent = AttributedString()
|
|
|
|
// Regular expression to find @mentions
|
|
let pattern = "@([a-zA-Z0-9_]+)"
|
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: contentText.count)) ?? []
|
|
|
|
var lastEndIndex = contentText.startIndex
|
|
|
|
for match in matches {
|
|
// Add text before the mention
|
|
if let range = Range(match.range(at: 0), in: contentText) {
|
|
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
if !beforeText.isEmpty {
|
|
var normalStyle = AttributeContainer()
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
}
|
|
|
|
// Add the mention with highlight
|
|
let mentionText = String(contentText[range])
|
|
var mentionStyle = AttributeContainer()
|
|
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
|
mentionStyle.foregroundColor = Color.orange
|
|
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
|
|
|
lastEndIndex = range.upperBound
|
|
}
|
|
}
|
|
|
|
// Add any remaining text
|
|
if lastEndIndex < contentText.endIndex {
|
|
let remainingText = String(contentText[lastEndIndex...])
|
|
var normalStyle = AttributeContainer()
|
|
normalStyle.font = .system(size: 14, design: .monospaced)
|
|
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
|
}
|
|
|
|
result.append(processedContent)
|
|
|
|
if message.isRelay, let originalSender = message.originalSender {
|
|
let relay = AttributedString(" (via \(originalSender))")
|
|
var relayStyle = AttributeContainer()
|
|
relayStyle.foregroundColor = secondaryColor
|
|
relayStyle.font = .system(size: 11, design: .monospaced)
|
|
result.append(relay.mergingAttributes(relayStyle))
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Noise Protocol Support
|
|
|
|
func updateEncryptionStatusForPeers() {
|
|
let noiseService = meshService.getNoiseService()
|
|
|
|
for peerID in connectedPeers {
|
|
if noiseService.hasEstablishedSession(with: peerID) {
|
|
// Check if fingerprint is verified using our persisted data
|
|
if let fingerprint = noiseService.getPeerFingerprint(peerID),
|
|
verifiedFingerprints.contains(fingerprint) {
|
|
peerEncryptionStatus[peerID] = .noiseVerified
|
|
} else {
|
|
peerEncryptionStatus[peerID] = .noiseSecured
|
|
}
|
|
} else {
|
|
// Always use Noise - no legacy encryption
|
|
peerEncryptionStatus[peerID] = .noiseHandshaking
|
|
}
|
|
}
|
|
}
|
|
|
|
func getEncryptionStatus(for peerID: String) -> EncryptionStatus {
|
|
// This must be a pure function - no state mutations allowed
|
|
// to avoid SwiftUI update loops
|
|
|
|
// Check if we have a fingerprint for this peer
|
|
if let fingerprint = getFingerprint(for: peerID) {
|
|
// Check if this fingerprint is verified
|
|
if verifiedFingerprints.contains(fingerprint) {
|
|
// Return verified if we have a Noise session
|
|
if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
|
return .noiseVerified
|
|
}
|
|
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
|
return .noiseSecured
|
|
}
|
|
}
|
|
|
|
// Fall back to stored status
|
|
return peerEncryptionStatus[peerID] ?? .none
|
|
}
|
|
|
|
// Update encryption status in appropriate places, not during view updates
|
|
private func updateEncryptionStatus(for peerID: String) {
|
|
if let fingerprint = getFingerprint(for: peerID) {
|
|
if verifiedFingerprints.contains(fingerprint) && meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
|
peerEncryptionStatus[peerID] = .noiseVerified
|
|
} else if meshService.getNoiseService().hasEstablishedSession(with: peerID) {
|
|
peerEncryptionStatus[peerID] = .noiseSecured
|
|
}
|
|
}
|
|
}
|
|
|
|
func showFingerprint(for peerID: String) {
|
|
showingFingerprintFor = peerID
|
|
}
|
|
|
|
func getFingerprint(for peerID: String) -> String? {
|
|
// Remove debug logging to prevent console spam during view updates
|
|
|
|
// First try to get fingerprint from mesh service's peer ID rotation mapping
|
|
if let fingerprint = meshService.getFingerprint(for: peerID) {
|
|
return fingerprint
|
|
}
|
|
|
|
// Fallback to noise service (direct Noise session fingerprint)
|
|
if let fingerprint = meshService.getNoiseService().getPeerFingerprint(peerID) {
|
|
return fingerprint
|
|
}
|
|
|
|
// Last resort: check local mapping
|
|
if let fingerprint = peerIDToPublicKeyFingerprint[peerID] {
|
|
return fingerprint
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper to resolve nickname for a peer ID through various sources
|
|
func resolveNickname(for peerID: String) -> String {
|
|
// Guard against empty or very short peer IDs
|
|
guard !peerID.isEmpty else {
|
|
return "unknown"
|
|
}
|
|
|
|
// Check if this might already be a nickname (not a hex peer ID)
|
|
// Peer IDs are hex strings, so they only contain 0-9 and a-f
|
|
let isHexID = peerID.allSatisfy { $0.isHexDigit }
|
|
if !isHexID {
|
|
// If it's already a nickname, just return it
|
|
return peerID
|
|
}
|
|
|
|
// First try direct peer nicknames from mesh service
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
if let nickname = peerNicknames[peerID] {
|
|
return nickname
|
|
}
|
|
|
|
// Try to resolve through fingerprint and social identity
|
|
if let fingerprint = getFingerprint(for: peerID) {
|
|
if let identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
|
|
// Prefer local petname if set
|
|
if let petname = identity.localPetname {
|
|
return petname
|
|
}
|
|
// Otherwise use their claimed nickname
|
|
return identity.claimedNickname
|
|
}
|
|
}
|
|
|
|
// Fallback to anonymous with shortened peer ID
|
|
// Ensure we have at least 4 characters for the prefix
|
|
let prefixLength = min(4, peerID.count)
|
|
let prefix = String(peerID.prefix(prefixLength))
|
|
|
|
// Avoid "anonanon" by checking if ID already starts with "anon"
|
|
if prefix.starts(with: "anon") {
|
|
return "peer\(prefix)"
|
|
}
|
|
return "anon\(prefix)"
|
|
}
|
|
|
|
func getMyFingerprint() -> String {
|
|
let fingerprint = meshService.getNoiseService().getIdentityFingerprint()
|
|
return fingerprint
|
|
}
|
|
|
|
func verifyFingerprint(for peerID: String) {
|
|
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
|
|
|
// Update secure storage with verified status
|
|
SecureIdentityStateManager.shared.setVerified(fingerprint: fingerprint, verified: true)
|
|
|
|
// Update local set for UI
|
|
verifiedFingerprints.insert(fingerprint)
|
|
|
|
// Update encryption status after verification
|
|
updateEncryptionStatus(for: peerID)
|
|
}
|
|
|
|
func loadVerifiedFingerprints() {
|
|
// Load verified fingerprints from secure storage
|
|
let allIdentities = SecureIdentityStateManager.shared.getAllSocialIdentities()
|
|
verifiedFingerprints = Set(allIdentities.filter { $0.trustLevel == .verified }.map { $0.fingerprint })
|
|
}
|
|
|
|
private func setupNoiseCallbacks() {
|
|
let noiseService = meshService.getNoiseService()
|
|
|
|
// Set up authentication callback
|
|
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
|
DispatchQueue.main.async {
|
|
// Update encryption status
|
|
if self?.verifiedFingerprints.contains(fingerprint) == true {
|
|
self?.peerEncryptionStatus[peerID] = .noiseVerified
|
|
} else {
|
|
self?.peerEncryptionStatus[peerID] = .noiseSecured
|
|
}
|
|
|
|
// Log for testing
|
|
#if DEBUG
|
|
NoiseTestingHelper.shared.logNoiseEvent("Peer authenticated", details: "PeerID: \(peerID), Fingerprint: \(fingerprint)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// Set up handshake required callback
|
|
noiseService.onHandshakeRequired = { [weak self] peerID in
|
|
DispatchQueue.main.async {
|
|
self?.peerEncryptionStatus[peerID] = .noiseHandshaking
|
|
|
|
#if DEBUG
|
|
NoiseTestingHelper.shared.logNoiseEvent("Handshake required", details: "PeerID: \(peerID)")
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ChatViewModel: BitchatDelegate {
|
|
func didReceiveChannelLeave(_ channel: String, from peerID: String) {
|
|
// Remove peer from channel members
|
|
if channelMembers[channel] != nil {
|
|
channelMembers[channel]?.remove(peerID)
|
|
|
|
// Force UI update
|
|
objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
func didReceivePasswordProtectedChannelAnnouncement(_ channel: String, isProtected: Bool, creatorID: String?, keyCommitment: String?) {
|
|
let wasAlreadyProtected = passwordProtectedChannels.contains(channel)
|
|
|
|
if isProtected {
|
|
passwordProtectedChannels.insert(channel)
|
|
if let creator = creatorID {
|
|
// Don't overwrite our own ownership
|
|
let myFingerprint = getMyFingerprint()
|
|
if channelCreators[channel] == nil ||
|
|
(channelCreators[channel] != meshService.myPeerID && channelCreators[channel] != myFingerprint) {
|
|
channelCreators[channel] = creator
|
|
// Received creator announcement
|
|
}
|
|
}
|
|
|
|
// Store the key commitment if provided
|
|
if let commitment = keyCommitment {
|
|
channelKeyCommitments[channel] = commitment
|
|
}
|
|
|
|
// If we just learned this channel is protected and we're in it without a key, prompt for password
|
|
if !wasAlreadyProtected && joinedChannels.contains(channel) && channelKeys[channel] == nil {
|
|
|
|
// Add system message
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "channel \(channel) is password protected. you need the password to participate.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
|
|
// If currently viewing this channel, show password prompt
|
|
if currentChannel == channel {
|
|
passwordPromptChannel = channel
|
|
showPasswordPrompt = true
|
|
}
|
|
}
|
|
} else {
|
|
passwordProtectedChannels.remove(channel)
|
|
// If we're in this channel and it's no longer protected, clear the key
|
|
channelKeys.removeValue(forKey: channel)
|
|
channelPasswords.removeValue(forKey: channel)
|
|
channelKeyCommitments.removeValue(forKey: channel)
|
|
}
|
|
|
|
// Save updated channel data
|
|
saveChannelData()
|
|
|
|
}
|
|
|
|
func decryptChannelMessage(_ encryptedContent: Data, channel: String, testKey: SymmetricKey? = nil) -> String? {
|
|
let key = testKey ?? channelKeys[channel]
|
|
guard let key = key else {
|
|
return nil
|
|
}
|
|
|
|
// Debug logging removed
|
|
|
|
do {
|
|
let sealedBox = try AES.GCM.SealedBox(combined: encryptedContent)
|
|
let decryptedData = try AES.GCM.open(sealedBox, using: key)
|
|
let decryptedString = String(data: decryptedData, encoding: .utf8)
|
|
return decryptedString
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func didReceiveChannelRetentionAnnouncement(_ channel: String, enabled: Bool, creatorID: String?) {
|
|
// This feature has been removed
|
|
}
|
|
|
|
private func handleCommand(_ command: String) {
|
|
let parts = command.split(separator: " ")
|
|
guard let cmd = parts.first else { return }
|
|
|
|
switch cmd {
|
|
case "/j", "/join":
|
|
if parts.count > 1 {
|
|
let channelName = String(parts[1])
|
|
// Ensure channel name starts with #
|
|
let channel = channelName.hasPrefix("#") ? channelName : "#\(channelName)"
|
|
|
|
// Validate channel name
|
|
let cleanedName = channel.dropFirst()
|
|
let isValidName = !cleanedName.isEmpty && cleanedName.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" }
|
|
|
|
if !isValidName {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "invalid channel name. use only letters, numbers, and underscores.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let wasAlreadyJoined = joinedChannels.contains(channel)
|
|
let wasPasswordProtected = passwordProtectedChannels.contains(channel)
|
|
let hadCreator = channelCreators[channel] != nil
|
|
|
|
let success = joinChannel(channel)
|
|
|
|
if success {
|
|
if !wasAlreadyJoined {
|
|
var message = "joined channel \(channel)"
|
|
if !hadCreator && !wasPasswordProtected {
|
|
message += " (created new channel - you are the owner)"
|
|
}
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: message,
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
// Already in channel, just switched to it
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "switched to channel \(channel)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
}
|
|
// If not successful, password prompt will be shown
|
|
}
|
|
} else {
|
|
// Show usage hint
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /j #channelname",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/create":
|
|
// /create is now just an alias for /join
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "use /join #channelname to join or create a channel",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
case "/m", "/msg":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
startPrivateChat(with: peerID)
|
|
|
|
// If there's a message after the nickname, send it
|
|
if parts.count > 2 {
|
|
let messageContent = parts[2...].joined(separator: " ")
|
|
sendPrivateMessage(messageContent, to: peerID)
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "started private chat with \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "user '\(nickname)' not found. they may be offline or using a different nickname.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /m @nickname [message] or /m nickname [message]",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/channels":
|
|
// Discover all channels (both joined and not joined)
|
|
var allChannels: Set<String> = Set()
|
|
|
|
// Add joined channels
|
|
allChannels.formUnion(joinedChannels)
|
|
|
|
// Find channels from messages we've seen
|
|
for msg in messages {
|
|
if let channel = msg.channel {
|
|
allChannels.insert(channel)
|
|
}
|
|
}
|
|
|
|
// Also check channel messages we've cached
|
|
for (channel, _) in channelMessages {
|
|
allChannels.insert(channel)
|
|
}
|
|
|
|
// Add password protected channels we know about
|
|
allChannels.formUnion(passwordProtectedChannels)
|
|
|
|
if allChannels.isEmpty {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "no channels discovered yet. channels appear as people use them.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let channelList = allChannels.sorted().map { channel in
|
|
var status = ""
|
|
if joinedChannels.contains(channel) {
|
|
status += " ✓"
|
|
}
|
|
if passwordProtectedChannels.contains(channel) {
|
|
status += " 🔒"
|
|
}
|
|
let myFingerprint = getMyFingerprint()
|
|
if channelCreators[channel] == meshService.myPeerID || channelCreators[channel] == myFingerprint {
|
|
status += " (owner)"
|
|
}
|
|
return "\(channel)\(status)"
|
|
}.joined(separator: "\n")
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "discovered channels:\n\(channelList)\n\n✓ = joined, 🔒 = password protected, 📌 = retention enabled",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/w":
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
if connectedPeers.isEmpty {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "no one else is online right now.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let onlineList = connectedPeers.compactMap { peerID in
|
|
peerNicknames[peerID]
|
|
}.sorted().joined(separator: ", ")
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "online users: \(onlineList)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
case "/transfer":
|
|
// Transfer channel ownership
|
|
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
|
|
if parts.count < 2 {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /transfer @nickname",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
transferChannelOwnership(to: parts[1])
|
|
}
|
|
case "/pass":
|
|
// Change channel password (only available in channels)
|
|
guard currentChannel != nil else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "you must be in a channel to use /pass.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
break
|
|
}
|
|
let parts = command.split(separator: " ", maxSplits: 1).map(String.init)
|
|
if parts.count < 2 {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /pass <new password>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
changeChannelPassword(to: parts[1])
|
|
}
|
|
case "/clear":
|
|
// Clear messages based on current context
|
|
if let channel = currentChannel {
|
|
// Clear channel messages
|
|
channelMessages[channel]?.removeAll()
|
|
} else if let peerID = selectedPrivateChatPeer {
|
|
// Clear private chat
|
|
privateChats[peerID]?.removeAll()
|
|
} else {
|
|
// Clear main messages
|
|
messages.removeAll()
|
|
}
|
|
case "/debug":
|
|
// Debug command to check ownership info
|
|
if let channel = currentChannel {
|
|
let myPeerID = meshService.myPeerID
|
|
let myFingerprint = getMyFingerprint()
|
|
let creator = channelCreators[channel] ?? "unknown"
|
|
let isOwner = isChannelOwner(channel)
|
|
|
|
let debugInfo = """
|
|
🐛 Debug Info for \(channel):
|
|
• Channel creator: \(creator)
|
|
• My peer ID: \(myPeerID)
|
|
• My fingerprint: \(myFingerprint)
|
|
• Am I owner? \(isOwner ? "YES" : "NO")
|
|
• All creators: \(channelCreators)
|
|
"""
|
|
|
|
let debugMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: debugInfo,
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(debugMsg)
|
|
} else {
|
|
let debugMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "use /debug in a channel to see ownership info",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(debugMsg)
|
|
}
|
|
case "/hug":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Check if target exists in connected peers
|
|
if let targetPeerID = getPeerIDForNickname(nickname) {
|
|
// Create hug message
|
|
let hugMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "🫂 \(self.nickname) hugs \(nickname)",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
recipientNickname: nickname,
|
|
senderPeerID: meshService.myPeerID,
|
|
channel: currentChannel
|
|
)
|
|
|
|
// Send as a regular message but it will be displayed as system message due to content
|
|
let hugContent = "* 🫂 \(self.nickname) hugs \(nickname) *"
|
|
if let channel = currentChannel {
|
|
meshService.sendMessage(hugContent, channel: channel)
|
|
// Add to channel messages
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
channelMessages[channel]?.append(hugMessage)
|
|
} else if selectedPrivateChatPeer != nil {
|
|
// In private chat, send as private message
|
|
if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
|
|
meshService.sendPrivateMessage("* 🫂 \(self.nickname) hugs you *", to: targetPeerID, recipientNickname: peerNickname)
|
|
}
|
|
} else {
|
|
// In public chat
|
|
meshService.sendMessage(hugContent)
|
|
messages.append(hugMessage)
|
|
}
|
|
} else {
|
|
let errorMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot hug \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMessage)
|
|
}
|
|
} else {
|
|
let usageMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /hug <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(usageMessage)
|
|
}
|
|
|
|
case "/slap":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Check if target exists in connected peers
|
|
if let targetPeerID = getPeerIDForNickname(nickname) {
|
|
// Create slap message
|
|
let slapMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
recipientNickname: nickname,
|
|
senderPeerID: meshService.myPeerID,
|
|
channel: currentChannel
|
|
)
|
|
|
|
// Send as a regular message but it will be displayed as system message due to content
|
|
let slapContent = "* 🐟 \(self.nickname) slaps \(nickname) around a bit with a large trout *"
|
|
if let channel = currentChannel {
|
|
meshService.sendMessage(slapContent, channel: channel)
|
|
// Add to channel messages
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
channelMessages[channel]?.append(slapMessage)
|
|
} else if selectedPrivateChatPeer != nil {
|
|
// In private chat, send as private message
|
|
if let peerNickname = meshService.getPeerNicknames()[targetPeerID] {
|
|
meshService.sendPrivateMessage("* 🐟 \(self.nickname) slaps you around a bit with a large trout *", to: targetPeerID, recipientNickname: peerNickname)
|
|
}
|
|
} else {
|
|
// In public chat
|
|
meshService.sendMessage(slapContent)
|
|
messages.append(slapMessage)
|
|
}
|
|
} else {
|
|
let errorMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot slap \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMessage)
|
|
}
|
|
} else {
|
|
let usageMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /slap <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(usageMessage)
|
|
}
|
|
|
|
case "/block":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Get fingerprint for persistent blocking
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
|
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(nickname) is already blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
// Update or create social identity with blocked status
|
|
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprintStr) {
|
|
identity.isBlocked = true
|
|
identity.isFavorite = false // Remove from favorites if blocked
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
|
|
} else {
|
|
let blockedIdentity = SocialIdentity(
|
|
fingerprint: fingerprintStr,
|
|
localPetname: nil,
|
|
claimedNickname: nickname,
|
|
trustLevel: .unknown,
|
|
isFavorite: false,
|
|
isBlocked: true,
|
|
notes: nil
|
|
)
|
|
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
|
|
}
|
|
|
|
// Update local sets for UI
|
|
blockedUsers.insert(fingerprintStr)
|
|
favoritePeers.remove(fingerprintStr)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "blocked \(nickname). you will no longer receive messages from them.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot block \(nickname): unable to verify identity.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot block \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
// List blocked users
|
|
if blockedUsers.isEmpty {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "no blocked peers.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
// Find nicknames for blocked users
|
|
var blockedNicknames: [String] = []
|
|
for (peerID, _) in meshService.getPeerNicknames() {
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
if blockedUsers.contains(fingerprintStr) {
|
|
if let nickname = meshService.getPeerNicknames()[peerID] {
|
|
blockedNicknames.append(nickname)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let blockedList = blockedNicknames.isEmpty ? "blocked peers (not currently online)" : blockedNicknames.sorted().joined(separator: ", ")
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "blocked peers: \(blockedList)",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
}
|
|
|
|
case "/unblock":
|
|
if parts.count > 1 {
|
|
let targetName = String(parts[1])
|
|
// Remove @ if present
|
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
|
|
// Find peer ID for this nickname
|
|
if let peerID = getPeerIDForNickname(nickname) {
|
|
// Get fingerprint
|
|
if let fingerprintStr = meshService.getPeerFingerprint(peerID) {
|
|
|
|
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprintStr) {
|
|
// Update social identity to unblock
|
|
SecureIdentityStateManager.shared.setBlocked(fingerprintStr, isBlocked: false)
|
|
|
|
// Update local set for UI
|
|
blockedUsers.remove(fingerprintStr)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "unblocked \(nickname).",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(nickname) is not blocked.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot unblock \(nickname): unable to verify identity.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "cannot unblock \(nickname): user not found.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
} else {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "usage: /unblock <nickname>",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
|
|
default:
|
|
// Unknown command
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "unknown command: \(cmd).",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
}
|
|
}
|
|
|
|
func didReceiveMessage(_ message: BitchatMessage) {
|
|
|
|
// Track sender as a channel member if this is a channel message
|
|
if let channel = message.channel, let senderPeerID = message.senderPeerID {
|
|
if channelMembers[channel] == nil {
|
|
channelMembers[channel] = Set()
|
|
}
|
|
channelMembers[channel]?.insert(senderPeerID)
|
|
}
|
|
|
|
// Check if sender is blocked (for both private and public messages)
|
|
if let senderPeerID = message.senderPeerID {
|
|
if isPeerBlocked(senderPeerID) {
|
|
// Silently ignore messages from blocked users
|
|
return
|
|
}
|
|
} else if let peerID = getPeerIDForNickname(message.sender) {
|
|
if isPeerBlocked(peerID) {
|
|
// Silently ignore messages from blocked users
|
|
return
|
|
}
|
|
}
|
|
|
|
if message.isPrivate {
|
|
// Handle private message
|
|
|
|
// Use the senderPeerID from the message if available
|
|
let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender)
|
|
|
|
if let peerID = senderPeerID {
|
|
// Message from someone else
|
|
|
|
// First check if we need to migrate existing messages from this sender
|
|
let senderNickname = message.sender
|
|
if privateChats[peerID] == nil || privateChats[peerID]?.isEmpty == true {
|
|
// Check if we have messages from this nickname under a different peer ID
|
|
var migratedMessages: [BitchatMessage] = []
|
|
var oldPeerIDsToRemove: [String] = []
|
|
|
|
for (oldPeerID, messages) in privateChats {
|
|
if oldPeerID != peerID {
|
|
// Check if this chat contains messages with this sender
|
|
let isRelevantChat = messages.contains { msg in
|
|
(msg.sender == senderNickname && msg.sender != nickname) ||
|
|
(msg.sender == nickname && msg.recipientNickname == senderNickname)
|
|
}
|
|
|
|
if isRelevantChat {
|
|
migratedMessages.append(contentsOf: messages)
|
|
oldPeerIDsToRemove.append(oldPeerID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove old peer ID entries
|
|
for oldPeerID in oldPeerIDsToRemove {
|
|
privateChats.removeValue(forKey: oldPeerID)
|
|
unreadPrivateMessages.remove(oldPeerID)
|
|
}
|
|
|
|
// Initialize with migrated messages
|
|
privateChats[peerID] = migratedMessages
|
|
}
|
|
|
|
if privateChats[peerID] == nil {
|
|
privateChats[peerID] = []
|
|
}
|
|
|
|
// Fix delivery status for incoming messages
|
|
var messageToStore = message
|
|
if message.sender != nickname {
|
|
// This is an incoming message - it should NOT have "sending" status
|
|
if messageToStore.deliveryStatus == nil || messageToStore.deliveryStatus == .sending {
|
|
// Mark it as delivered since we received it
|
|
messageToStore.deliveryStatus = .delivered(to: nickname, at: Date())
|
|
}
|
|
}
|
|
|
|
// Check if this is an action that should be converted to system message
|
|
let isActionMessage = messageToStore.content.hasPrefix("* ") && messageToStore.content.hasSuffix(" *") &&
|
|
(messageToStore.content.contains("🫂") || messageToStore.content.contains("🐟") ||
|
|
messageToStore.content.contains("took a screenshot"))
|
|
|
|
if isActionMessage {
|
|
// Convert to system message
|
|
messageToStore = BitchatMessage(
|
|
id: messageToStore.id,
|
|
sender: "system",
|
|
content: String(messageToStore.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
|
|
timestamp: messageToStore.timestamp,
|
|
isRelay: messageToStore.isRelay,
|
|
originalSender: messageToStore.originalSender,
|
|
isPrivate: messageToStore.isPrivate,
|
|
recipientNickname: messageToStore.recipientNickname,
|
|
senderPeerID: messageToStore.senderPeerID,
|
|
mentions: messageToStore.mentions,
|
|
channel: messageToStore.channel,
|
|
deliveryStatus: messageToStore.deliveryStatus
|
|
)
|
|
}
|
|
|
|
privateChats[peerID]?.append(messageToStore)
|
|
// Sort messages by timestamp to ensure proper ordering
|
|
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
|
|
// Debug logging
|
|
|
|
// Trigger UI update for private chats
|
|
objectWillChange.send()
|
|
|
|
// Check if we're in a private chat with this peer's fingerprint
|
|
// This handles reconnections with new peer IDs
|
|
if let chatFingerprint = selectedPrivateChatFingerprint,
|
|
let senderFingerprint = peerIDToPublicKeyFingerprint[peerID],
|
|
chatFingerprint == senderFingerprint && selectedPrivateChatPeer != peerID {
|
|
// Update our private chat peer to the new ID
|
|
selectedPrivateChatPeer = peerID
|
|
}
|
|
|
|
// Mark as unread if not currently viewing this chat
|
|
if selectedPrivateChatPeer != peerID {
|
|
unreadPrivateMessages.insert(peerID)
|
|
|
|
} else {
|
|
// We're viewing this chat, make sure unread is cleared
|
|
unreadPrivateMessages.remove(peerID)
|
|
|
|
// Send read receipt immediately since we're viewing the chat
|
|
// Send to the current peer ID since peer IDs change between sessions
|
|
if !sentReadReceipts.contains(message.id) {
|
|
let receipt = ReadReceipt(
|
|
originalMessageID: message.id,
|
|
readerID: meshService.myPeerID,
|
|
readerNickname: nickname
|
|
)
|
|
meshService.sendReadReceipt(receipt, to: peerID)
|
|
sentReadReceipts.insert(message.id)
|
|
}
|
|
|
|
// Also check if there are other unread messages from this peer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
self?.markPrivateMessagesAsRead(from: peerID)
|
|
}
|
|
}
|
|
} else if message.sender == nickname {
|
|
// Our own message that was echoed back - ignore it since we already added it locally
|
|
}
|
|
} else if let channel = message.channel {
|
|
// Channel message
|
|
|
|
// Only process channel messages if we've joined this channel
|
|
if joinedChannels.contains(channel) {
|
|
// Prepare the message to add (might be updated if decryption succeeds)
|
|
var messageToAdd = message
|
|
|
|
// Check if this is an encrypted message and we don't have the key
|
|
if message.isEncrypted && channelKeys[channel] == nil {
|
|
// Mark channel as password protected if not already
|
|
let wasNewlyDiscovered = !passwordProtectedChannels.contains(channel)
|
|
if wasNewlyDiscovered {
|
|
passwordProtectedChannels.insert(channel)
|
|
saveChannelData()
|
|
|
|
// Add a system message to indicate the channel is password protected (only once)
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "channel \(channel) is password protected. you need the password to read messages.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
channelMessages[channel]?.append(systemMessage)
|
|
}
|
|
|
|
// If we're currently viewing this channel, prompt for password
|
|
if currentChannel == channel {
|
|
passwordPromptChannel = channel
|
|
showPasswordPrompt = true
|
|
}
|
|
} else if message.isEncrypted && channelKeys[channel] != nil && message.content == "[Encrypted message - password required]" {
|
|
// We have a key but the message shows as encrypted - try to decrypt it again
|
|
|
|
// Check if this is the first encrypted message in the channel (password verification opportunity)
|
|
let isFirstEncryptedMessage = channelMessages[channel]?.filter { $0.isEncrypted }.isEmpty ?? true
|
|
|
|
if let encryptedData = message.encryptedContent {
|
|
if let decryptedContent = decryptChannelMessage(encryptedData, channel: channel) {
|
|
// Successfully decrypted - update the message content
|
|
|
|
if isFirstEncryptedMessage {
|
|
|
|
// Add success message
|
|
let verifiedMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "password verified successfully for channel \(channel).",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(verifiedMsg)
|
|
}
|
|
|
|
// Create a new message with decrypted content
|
|
let decryptedMessage = BitchatMessage(
|
|
sender: message.sender,
|
|
content: decryptedContent,
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: message.isPrivate,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID,
|
|
mentions: message.mentions,
|
|
channel: message.channel,
|
|
encryptedContent: message.encryptedContent,
|
|
isEncrypted: message.isEncrypted
|
|
)
|
|
|
|
// Update the message we'll add
|
|
messageToAdd = decryptedMessage
|
|
} else {
|
|
// Decryption really failed - wrong password
|
|
|
|
// Clear the wrong password
|
|
channelKeys.removeValue(forKey: channel)
|
|
channelPasswords.removeValue(forKey: channel)
|
|
|
|
// If this was the first encrypted message, we need to kick the user out
|
|
if isFirstEncryptedMessage {
|
|
|
|
// Leave the channel
|
|
joinedChannels.remove(channel)
|
|
saveJoinedChannels()
|
|
|
|
// Clear channel data
|
|
channelMessages.removeValue(forKey: channel)
|
|
channelMembers.removeValue(forKey: channel)
|
|
unreadChannelMessages.removeValue(forKey: channel)
|
|
|
|
// If we're currently in this channel, exit to main
|
|
if currentChannel == channel {
|
|
currentChannel = nil
|
|
}
|
|
|
|
// Add error message
|
|
let errorMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "wrong password for channel \(channel). you have been removed from the channel.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMsg)
|
|
|
|
// Don't show password prompt - user needs to rejoin
|
|
return
|
|
}
|
|
|
|
// Add system message for subsequent failures
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "wrong password for channel \(channel). please enter the correct password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(systemMessage)
|
|
|
|
// Show password prompt again
|
|
if currentChannel == channel {
|
|
passwordPromptChannel = channel
|
|
showPasswordPrompt = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if this is an action that should be converted to system message
|
|
let isActionMessage = messageToAdd.content.hasPrefix("* ") && messageToAdd.content.hasSuffix(" *") &&
|
|
(messageToAdd.content.contains("🫂") || messageToAdd.content.contains("🐟") ||
|
|
messageToAdd.content.contains("took a screenshot"))
|
|
|
|
let finalMessage: BitchatMessage
|
|
if isActionMessage {
|
|
// Convert to system message
|
|
finalMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: String(messageToAdd.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
|
|
timestamp: messageToAdd.timestamp,
|
|
isRelay: messageToAdd.isRelay,
|
|
originalSender: messageToAdd.originalSender,
|
|
isPrivate: false,
|
|
recipientNickname: messageToAdd.recipientNickname,
|
|
senderPeerID: messageToAdd.senderPeerID,
|
|
mentions: messageToAdd.mentions,
|
|
channel: messageToAdd.channel
|
|
)
|
|
} else {
|
|
finalMessage = messageToAdd
|
|
}
|
|
|
|
// Add to channel messages (using potentially decrypted version)
|
|
if channelMessages[channel] == nil {
|
|
channelMessages[channel] = []
|
|
}
|
|
|
|
// Check if this is our own message being echoed back
|
|
if finalMessage.sender != nickname && finalMessage.sender != "system" {
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
channelMessages[channel]?.append(finalMessage)
|
|
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
} else if finalMessage.sender != "system" {
|
|
// Our own message - check if we already have it (by ID and content)
|
|
let messageExists = channelMessages[channel]?.contains { existingMsg in
|
|
// Check by ID first
|
|
if existingMsg.id == finalMessage.id {
|
|
return true
|
|
}
|
|
// Check by content and sender with time window (within 1 second)
|
|
if existingMsg.content == finalMessage.content &&
|
|
existingMsg.sender == finalMessage.sender {
|
|
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
|
|
return timeDiff < 1.0
|
|
}
|
|
return false
|
|
} ?? false
|
|
if !messageExists {
|
|
// This is a message we sent from another device or it's missing locally
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
channelMessages[channel]?.append(finalMessage)
|
|
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
}
|
|
} else {
|
|
// System message - always add
|
|
channelMessages[channel]?.append(finalMessage)
|
|
channelMessages[channel]?.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
|
|
// Track channel members - only track the sender as a member
|
|
if channelMembers[channel] == nil {
|
|
channelMembers[channel] = Set()
|
|
}
|
|
if let senderPeerID = message.senderPeerID {
|
|
channelMembers[channel]?.insert(senderPeerID)
|
|
} else {
|
|
}
|
|
|
|
// Update unread count if not currently viewing this channel and it's not our own message
|
|
if currentChannel != channel && finalMessage.sender != nickname {
|
|
unreadChannelMessages[channel] = (unreadChannelMessages[channel] ?? 0) + 1
|
|
}
|
|
} else {
|
|
// We're not in this channel, ignore the message
|
|
}
|
|
} else {
|
|
// Regular public message (main chat)
|
|
|
|
// Check if this is an action that should be converted to system message
|
|
let isActionMessage = message.content.hasPrefix("* ") && message.content.hasSuffix(" *") &&
|
|
(message.content.contains("🫂") || message.content.contains("🐟") ||
|
|
message.content.contains("took a screenshot"))
|
|
|
|
let finalMessage: BitchatMessage
|
|
if isActionMessage {
|
|
// Convert to system message
|
|
finalMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: String(message.content.dropFirst(2).dropLast(2)), // Remove * * wrapper
|
|
timestamp: message.timestamp,
|
|
isRelay: message.isRelay,
|
|
originalSender: message.originalSender,
|
|
isPrivate: false,
|
|
recipientNickname: message.recipientNickname,
|
|
senderPeerID: message.senderPeerID,
|
|
mentions: message.mentions,
|
|
channel: message.channel
|
|
)
|
|
} else {
|
|
finalMessage = message
|
|
}
|
|
|
|
// Check if this is our own message being echoed back
|
|
if finalMessage.sender != nickname && finalMessage.sender != "system" {
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
messages.append(finalMessage)
|
|
// Sort messages by timestamp to ensure proper ordering
|
|
messages.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
} else if finalMessage.sender != "system" {
|
|
// Our own message - check if we already have it (by ID and content)
|
|
let messageExists = messages.contains { existingMsg in
|
|
// Check by ID first
|
|
if existingMsg.id == finalMessage.id {
|
|
return true
|
|
}
|
|
// Check by content and sender with time window (within 1 second)
|
|
if existingMsg.content == finalMessage.content &&
|
|
existingMsg.sender == finalMessage.sender {
|
|
let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp))
|
|
return timeDiff < 1.0
|
|
}
|
|
return false
|
|
}
|
|
if !messageExists {
|
|
// This is a message we sent from another device or it's missing locally
|
|
// Skip empty or whitespace-only messages
|
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
messages.append(finalMessage)
|
|
messages.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
}
|
|
} else {
|
|
// System message - always add
|
|
messages.append(finalMessage)
|
|
messages.sort { $0.timestamp < $1.timestamp }
|
|
}
|
|
}
|
|
|
|
// Check if we're mentioned
|
|
let isMentioned = message.mentions?.contains(nickname) ?? false
|
|
|
|
// Send notifications for mentions and private messages when app is in background
|
|
if isMentioned && message.sender != nickname {
|
|
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
|
} else if message.isPrivate && message.sender != nickname {
|
|
NotificationService.shared.sendPrivateMessageNotification(from: message.sender, message: message.content)
|
|
}
|
|
|
|
#if os(iOS)
|
|
// Haptic feedback for iOS only
|
|
guard UIApplication.shared.applicationState == .active else {
|
|
return
|
|
}
|
|
// Check if this is a hug message directed at the user
|
|
let isHugForMe = message.content.contains("🫂") &&
|
|
(message.content.contains("hugs \(nickname)") ||
|
|
message.content.contains("hugs you"))
|
|
|
|
// Check if this is a slap message directed at the user
|
|
let isSlapForMe = message.content.contains("🐟") &&
|
|
(message.content.contains("slaps \(nickname) around") ||
|
|
message.content.contains("slaps you around"))
|
|
|
|
if isHugForMe && message.sender != nickname {
|
|
// Long warm haptic for hugs - continuous gentle vibration
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
impactFeedback.prepare()
|
|
|
|
// Create a warm, sustained haptic pattern
|
|
for i in 0..<8 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.15) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
}
|
|
|
|
// Add a final stronger pulse
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
let strongFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
strongFeedback.prepare()
|
|
strongFeedback.impactOccurred()
|
|
}
|
|
} else if isSlapForMe && message.sender != nickname {
|
|
// Very harsh, fast, strong haptic for slaps - multiple sharp impacts
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
|
|
// Rapid-fire heavy impacts to simulate a hard slap
|
|
impactFeedback.impactOccurred()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
|
|
// Final extra heavy impact
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
|
let finalImpact = UIImpactFeedbackGenerator(style: .heavy)
|
|
finalImpact.prepare()
|
|
finalImpact.impactOccurred()
|
|
}
|
|
} else if isMentioned && message.sender != nickname {
|
|
// Very prominent haptic for @mentions - triple tap with heavy impact
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
impactFeedback.impactOccurred()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
} else if message.isPrivate && message.sender != nickname {
|
|
// Heavy haptic for private messages - more pronounced
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
impactFeedback.impactOccurred()
|
|
|
|
// Double tap for extra emphasis
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
} else if message.sender != nickname {
|
|
// Light haptic for public messages from others
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .light)
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func didConnectToPeer(_ peerID: String) {
|
|
isConnected = true
|
|
|
|
// Register ephemeral session with identity manager
|
|
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
|
|
|
|
// Resolve nickname using helper
|
|
let displayName = resolveNickname(for: peerID)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(displayName) connected",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil
|
|
)
|
|
messages.append(systemMessage)
|
|
|
|
// Force UI update
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func didDisconnectFromPeer(_ peerID: String) {
|
|
// Remove ephemeral session from identity manager
|
|
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
|
|
|
// Resolve nickname using helper
|
|
let displayName = resolveNickname(for: peerID)
|
|
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: "\(displayName) disconnected",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
originalSender: nil
|
|
)
|
|
messages.append(systemMessage)
|
|
|
|
// Force UI update
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func didUpdatePeerList(_ peers: [String]) {
|
|
// UI updates must run on the main thread.
|
|
// The delegate callback is not guaranteed to be on the main thread.
|
|
DispatchQueue.main.async {
|
|
self.connectedPeers = peers
|
|
self.isConnected = !peers.isEmpty
|
|
|
|
// Register ephemeral sessions for all connected peers
|
|
for peerID in peers {
|
|
SecureIdentityStateManager.shared.registerEphemeralSession(peerID: peerID)
|
|
}
|
|
|
|
// Update encryption status for all peers
|
|
self.updateEncryptionStatusForPeers()
|
|
|
|
// Clean up channel members who disconnected
|
|
for (channel, memberIDs) in self.channelMembers {
|
|
let activeMembers = memberIDs.filter { memberID in
|
|
memberID == self.meshService.myPeerID || peers.contains(memberID)
|
|
}
|
|
if activeMembers != memberIDs {
|
|
self.channelMembers[channel] = activeMembers
|
|
}
|
|
}
|
|
// Explicitly notify SwiftUI that the object has changed.
|
|
self.objectWillChange.send()
|
|
|
|
// Check if we need to update private chat peer after reconnection
|
|
if self.selectedPrivateChatFingerprint != nil {
|
|
self.updatePrivateChatPeerIfNeeded()
|
|
}
|
|
|
|
// Only end private chat if we can't find the peer by fingerprint
|
|
if let currentChatPeer = self.selectedPrivateChatPeer,
|
|
!peers.contains(currentChatPeer),
|
|
self.selectedPrivateChatFingerprint != nil {
|
|
// Try one more time to find by fingerprint
|
|
if self.getCurrentPeerIDForFingerprint(self.selectedPrivateChatFingerprint!) == nil {
|
|
self.endPrivateChat()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func parseMentions(from content: String) -> [String] {
|
|
let pattern = "@([a-zA-Z0-9_]+)"
|
|
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
|
|
|
var mentions: [String] = []
|
|
let peerNicknames = meshService.getPeerNicknames()
|
|
let allNicknames = Set(peerNicknames.values).union([nickname]) // Include self
|
|
|
|
for match in matches {
|
|
if let range = Range(match.range(at: 1), in: content) {
|
|
let mentionedName = String(content[range])
|
|
// Only include if it's a valid nickname
|
|
if allNicknames.contains(mentionedName) {
|
|
mentions.append(mentionedName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array(Set(mentions)) // Remove duplicates
|
|
}
|
|
|
|
func isFavorite(fingerprint: String) -> Bool {
|
|
return SecureIdentityStateManager.shared.isFavorite(fingerprint: fingerprint)
|
|
}
|
|
|
|
func didReceiveDeliveryAck(_ ack: DeliveryAck) {
|
|
// Find the message and update its delivery status
|
|
updateMessageDeliveryStatus(ack.originalMessageID, status: .delivered(to: ack.recipientNickname, at: ack.timestamp))
|
|
}
|
|
|
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
|
// Find the message and update its read status
|
|
updateMessageDeliveryStatus(receipt.originalMessageID, status: .read(by: receipt.readerNickname, at: receipt.timestamp))
|
|
|
|
// Clear delivery tracking since the message has been read
|
|
// This prevents the timeout from marking it as failed
|
|
DeliveryTracker.shared.clearDeliveryStatus(for: receipt.originalMessageID)
|
|
}
|
|
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
updateMessageDeliveryStatus(messageID, status: status)
|
|
}
|
|
|
|
private func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
|
print("🔄 Updating UI delivery status for message \(messageID): \(status)")
|
|
|
|
// Helper function to check if we should skip this update
|
|
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
|
guard let current = currentStatus else { return false }
|
|
|
|
// Don't downgrade from read to delivered
|
|
switch (current, newStatus) {
|
|
case (.read, .delivered):
|
|
return true
|
|
case (.read, .sent):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Update in main messages
|
|
if let index = messages.firstIndex(where: { $0.id == messageID }) {
|
|
let currentStatus = messages[index].deliveryStatus
|
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
|
var updatedMessage = messages[index]
|
|
updatedMessage.deliveryStatus = status
|
|
messages[index] = updatedMessage
|
|
}
|
|
}
|
|
|
|
// Update in private chats
|
|
var updatedPrivateChats = privateChats
|
|
for (peerID, var chatMessages) in updatedPrivateChats {
|
|
if let index = chatMessages.firstIndex(where: { $0.id == messageID }) {
|
|
let currentStatus = chatMessages[index].deliveryStatus
|
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
|
var updatedMessage = chatMessages[index]
|
|
updatedMessage.deliveryStatus = status
|
|
chatMessages[index] = updatedMessage
|
|
updatedPrivateChats[peerID] = chatMessages
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force complete reassignment to trigger SwiftUI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
self.privateChats = updatedPrivateChats
|
|
self.objectWillChange.send()
|
|
}
|
|
|
|
// Update in channel messages
|
|
for (channel, var channelMsgs) in channelMessages {
|
|
if let index = channelMsgs.firstIndex(where: { $0.id == messageID }) {
|
|
let currentStatus = channelMsgs[index].deliveryStatus
|
|
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
|
var updatedMessage = channelMsgs[index]
|
|
updatedMessage.deliveryStatus = status
|
|
channelMsgs[index] = updatedMessage
|
|
channelMessages[channel] = channelMsgs
|
|
|
|
// Force UI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Channel Key Verification
|
|
|
|
func didReceiveChannelKeyVerifyRequest(_ request: ChannelKeyVerifyRequest, from peerID: String) {
|
|
|
|
// Check if we have the key for this channel
|
|
guard let myKey = channelKeys[request.channel] else {
|
|
// We don't have the key, can't verify
|
|
return
|
|
}
|
|
|
|
// Compute our key commitment
|
|
let myCommitment = computeKeyCommitment(for: myKey)
|
|
|
|
// Check if their commitment matches ours
|
|
let verified = (myCommitment == request.keyCommitment)
|
|
|
|
// Send response
|
|
let response = ChannelKeyVerifyResponse(
|
|
channel: request.channel,
|
|
responderID: meshService.myPeerID,
|
|
verified: verified
|
|
)
|
|
|
|
meshService.sendChannelKeyVerifyResponse(response, to: peerID)
|
|
|
|
// If they have the wrong key, they might need the new password
|
|
let myFingerprint = getMyFingerprint()
|
|
if !verified && (channelCreators[request.channel] == meshService.myPeerID || channelCreators[request.channel] == myFingerprint) {
|
|
// We're the owner and they have wrong key - could offer to send update
|
|
}
|
|
}
|
|
|
|
func didReceiveChannelKeyVerifyResponse(_ response: ChannelKeyVerifyResponse, from peerID: String) {
|
|
|
|
if response.verified {
|
|
// Our key was verified - we have the correct password
|
|
|
|
// Update verification status
|
|
channelVerificationStatus[response.channel] = .verified
|
|
|
|
// Mark channel as successfully joined
|
|
if joinedChannels.contains(response.channel) {
|
|
// Update UI to show successful verification
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
} else {
|
|
// Our key was rejected - we have the wrong password
|
|
|
|
// Update verification status
|
|
channelVerificationStatus[response.channel] = .failed
|
|
|
|
// Show error to user
|
|
let errorMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "incorrect password for \(response.channel). please check with channel members for the correct password.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
messages.append(errorMsg)
|
|
}
|
|
}
|
|
|
|
func didReceiveChannelPasswordUpdate(_ update: ChannelPasswordUpdate, from peerID: String) {
|
|
// Use the fingerprint from the update message (more reliable than looking up by peer ID)
|
|
let senderFingerprint = update.ownerFingerprint
|
|
|
|
// Verify sender is the channel owner by checking fingerprint
|
|
guard let channelOwnerFingerprint = channelCreators[update.channel],
|
|
channelOwnerFingerprint == senderFingerprint else {
|
|
// Security: Ignoring password update from non-owner
|
|
return
|
|
}
|
|
|
|
// Extract the new password
|
|
let newPassword = String(data: update.encryptedPassword, encoding: .utf8) ?? ""
|
|
|
|
// Verify the new key commitment matches
|
|
let newKey = deriveChannelKey(from: newPassword, channelName: update.channel)
|
|
let computedCommitment = computeKeyCommitment(for: newKey)
|
|
|
|
guard computedCommitment == update.newKeyCommitment else {
|
|
return
|
|
}
|
|
|
|
// Accept the new password
|
|
channelKeys[update.channel] = newKey
|
|
channelPasswords[update.channel] = newPassword
|
|
channelKeyCommitments[update.channel] = update.newKeyCommitment
|
|
|
|
// Save to keychain
|
|
_ = KeychainManager.shared.saveChannelPassword(newPassword, for: update.channel)
|
|
|
|
// Save channel data
|
|
saveChannelData()
|
|
|
|
// Notify user
|
|
let notificationMsg = BitchatMessage(
|
|
sender: "system",
|
|
content: "password for \(update.channel) has been updated by the channel owner.",
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
|
|
// Add to appropriate message list
|
|
if currentChannel == update.channel {
|
|
messages.append(notificationMsg)
|
|
} else if channelMessages[update.channel] != nil {
|
|
channelMessages[update.channel]?.append(notificationMsg)
|
|
}
|
|
|
|
// Force UI update
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
func didReceiveChannelMetadata(_ metadata: ChannelMetadata, from peerID: String) {
|
|
|
|
// Check if we already have metadata for this channel
|
|
if let existingMetadata = channelMetadata[metadata.channel] {
|
|
// Keep the older one (first creator wins)
|
|
if existingMetadata.createdAt < metadata.createdAt {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Store the metadata
|
|
channelMetadata[metadata.channel] = metadata
|
|
|
|
// Update channel creator info
|
|
if channelCreators[metadata.channel] == nil {
|
|
channelCreators[metadata.channel] = metadata.creatorFingerprint
|
|
|
|
if metadata.isPasswordProtected {
|
|
passwordProtectedChannels.insert(metadata.channel)
|
|
if let commitment = metadata.keyCommitment {
|
|
channelKeyCommitments[metadata.channel] = commitment
|
|
}
|
|
}
|
|
|
|
saveChannelData()
|
|
// Stored channel metadata
|
|
}
|
|
}
|
|
|
|
}
|