diff --git a/README.md b/README.md index a4c86560..c102dcea 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,15 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file ### Basic Commands - `/j #channel` - Join or create a channel -- `/m @user message` - Send a private message +- `/m @name message` - Send a private message - `/w` - List online users - `/channels` - Show all discovered channels +- `/block @name` - Block a peer from messaging you +- `/block` - List all blocked peers +- `/unblock @name` - Unblock a peer - `/clear` - Clear chat messages - `/pass [password]` - Set/change channel password (owner only) -- `/transfer @user` - Transfer channel ownership +- `/transfer @name` - Transfer channel ownership - `/save` - Toggle message retention for channel (owner only) ### Getting Started diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 09942d2e..9be8c266 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -249,6 +249,11 @@ class BluetoothMeshService: NSObject { return String(fingerprint) } + // Public method to get peer's public key data + func getPeerPublicKey(_ peerID: String) -> Data? { + return encryptionService.getPeerIdentityKey(peerID) + } + override init() { // Generate ephemeral peer ID for each session to prevent tracking // Use random bytes instead of UUID for better anonymity diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7b465fc5..e86da2bb 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -61,10 +61,12 @@ class ChatViewModel: ObservableObject { // private let channelPasswordsKey = "bitchat.channelPasswords" // Now using Keychain private let channelKeyCommitmentsKey = "bitchat.channelKeyCommitments" private let retentionEnabledChannelsKey = "bitchat.retentionEnabledChannels" + private let blockedUsersKey = "bitchat.blockedUsers" private var nicknameSaveTimer: Timer? @Published var favoritePeers: Set = [] // 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 = [] // Stores public key fingerprints of blocked users // Messages are naturally ephemeral - no persistent storage @@ -76,6 +78,7 @@ class ChatViewModel: ObservableObject { loadFavorites() loadJoinedChannels() loadChannelData() + loadBlockedUsers() // Load saved channels state savedChannels = MessageRetentionService.shared.getFavoriteChannels() meshService.delegate = self @@ -158,6 +161,17 @@ class ChatViewModel: ObservableObject { userDefaults.synchronize() } + private func loadBlockedUsers() { + if let savedBlockedUsers = userDefaults.stringArray(forKey: blockedUsersKey) { + blockedUsers = Set(savedBlockedUsers) + } + } + + private func saveBlockedUsers() { + userDefaults.set(Array(blockedUsers), forKey: blockedUsersKey) + userDefaults.synchronize() + } + private func loadJoinedChannels() { if let savedChannelsList = userDefaults.stringArray(forKey: joinedChannelsKey) { joinedChannels = Set(savedChannelsList) @@ -794,6 +808,25 @@ class ChatViewModel: ObservableObject { } } + private func isPeerBlocked(_ peerID: String) -> Bool { + // Check if we have the public key fingerprint for this peer + if let fingerprint = peerIDToPublicKeyFingerprint[peerID] { + return blockedUsers.contains(fingerprint) + } + + // Try to get public key from mesh service + if let publicKeyData = meshService.getPeerPublicKey(peerID) { + let fingerprint = SHA256.hash(data: publicKeyData) + .compactMap { String(format: "%02x", $0) } + .joined() + .prefix(16) + .lowercased() + return blockedUsers.contains(String(fingerprint)) + } + + return false + } + func sendMessage(_ content: String) { guard !content.isEmpty else { return } @@ -879,6 +912,18 @@ class ChatViewModel: ObservableObject { 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) @@ -915,6 +960,19 @@ class ChatViewModel: ObservableObject { 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 unreadPrivateMessages.remove(peerID) @@ -2132,6 +2190,172 @@ extension ChatViewModel: BitchatDelegate { 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 public key fingerprint for persistent blocking + if let publicKeyData = meshService.getPeerPublicKey(peerID) { + let fingerprint = SHA256.hash(data: publicKeyData) + .compactMap { String(format: "%02x", $0) } + .joined() + .prefix(16) + .lowercased() + let fingerprintStr = String(fingerprint) + + if blockedUsers.contains(fingerprintStr) { + let systemMessage = BitchatMessage( + sender: "system", + content: "\(nickname) is already blocked.", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } else { + blockedUsers.insert(fingerprintStr) + saveBlockedUsers() + + // Remove from favorites if blocked + if favoritePeers.contains(fingerprintStr) { + favoritePeers.remove(fingerprintStr) + saveFavorites() + } + + 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 publicKeyData = meshService.getPeerPublicKey(peerID) { + let fingerprint = SHA256.hash(data: publicKeyData) + .compactMap { String(format: "%02x", $0) } + .joined() + .prefix(16) + .lowercased() + let fingerprintStr = String(fingerprint) + 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 public key fingerprint + if let publicKeyData = meshService.getPeerPublicKey(peerID) { + let fingerprint = SHA256.hash(data: publicKeyData) + .compactMap { String(format: "%02x", $0) } + .joined() + .prefix(16) + .lowercased() + let fingerprintStr = String(fingerprint) + + if blockedUsers.contains(fingerprintStr) { + blockedUsers.remove(fingerprintStr) + saveBlockedUsers() + + 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 ", + timestamp: Date(), + isRelay: false + ) + messages.append(systemMessage) + } + default: // Unknown command let systemMessage = BitchatMessage( @@ -2146,6 +2370,19 @@ extension ChatViewModel: BitchatDelegate { func didReceiveMessage(_ message: BitchatMessage) { + // 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 diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index f6ca27b1..a261ecaf 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -102,6 +102,26 @@ struct AppInfoView: View { .foregroundColor(textColor) } + // Commands + VStack(alignment: .leading, spacing: 16) { + SectionHeader("Commands") + + VStack(alignment: .leading, spacing: 8) { + Text("/j #channel - join or create a channel") + Text("/m @name - send private message") + Text("/w - see who's online") + Text("/channels - show all discovered channels") + Text("/block @name - block a peer") + Text("/block - list blocked peers") + Text("/unblock @name - unblock a peer") + Text("/clear - clear current chat") + Text("/hug @name - send someone a hug") + Text("/slap @name - slap with a trout") + } + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + } + // Technical Details VStack(alignment: .leading, spacing: 16) { SectionHeader("Technical Details") @@ -208,6 +228,26 @@ struct AppInfoView: View { .foregroundColor(textColor) } + // Commands + VStack(alignment: .leading, spacing: 16) { + SectionHeader("Commands") + + VStack(alignment: .leading, spacing: 8) { + Text("/j #channel - join or create a channel") + Text("/m @name - send private message") + Text("/w - see who's online") + Text("/channels - show all discovered channels") + Text("/block @name - block a peer") + Text("/block - list blocked peers") + Text("/unblock @name - unblock a peer") + Text("/clear - clear current chat") + Text("/hug @name - send someone a hug") + Text("/slap @name - slap with a trout") + } + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + } + // Technical Details VStack(alignment: .leading, spacing: 16) { SectionHeader("Technical Details") diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 67238079..d0065c47 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -519,12 +519,14 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { // Define commands with aliases and syntax let commandInfo: [(commands: [String], syntax: String?, description: String)] = [ + (["/block"], "[nickname]", "block or list blocked peers"), (["/clear"], nil, "clear chat messages"), (["/hug"], "", "send someone a warm hug"), (["/j", "/join"], "", "join or create a channel"), (["/m", "/msg"], " [message]", "send private message"), (["/channels"], nil, "show all discovered channels"), (["/slap"], "", "slap someone with a trout"), + (["/unblock"], "", "unblock a peer"), (["/w"], nil, "see who's online") ] @@ -625,12 +627,14 @@ struct ContentView: View { if newValue.hasPrefix("/") && newValue.count >= 1 { // Build context-aware command list var commandDescriptions = [ + ("/block", "block or list blocked peers"), + ("/channels", "show all discovered channels"), ("/clear", "clear chat messages"), ("/hug", "send someone a warm hug"), ("/j", "join or create a channel"), ("/m", "send private message"), - ("/channels", "show all discovered channels"), ("/slap", "slap someone with a trout"), + ("/unblock", "unblock a peer"), ("/w", "see who's online") ]