diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index e3c99774..51a58c6c 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -149,6 +149,9 @@ struct IdentityCache: Codable { // Last interaction timestamps (privacy: optional) var lastInteractions: [String: Date] = [:] + // Blocked Nostr pubkeys (lowercased hex) for geohash chats + var blockedNostrPubkeys: Set = [] + // Schema version for future migrations var version: Int = 1 } @@ -222,4 +225,4 @@ enum ConnectionQuality { } // MARK: - Migration Support -// Removed LegacyFavorite - no longer needed \ No newline at end of file +// Removed LegacyFavorite - no longer needed diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index c25edb12..2a836826 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -335,6 +335,30 @@ class SecureIdentityStateManager { self.saveIdentityCache() } } + + // MARK: - Geohash (Nostr) Blocking + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + queue.sync { + return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) + } + } + + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { + let key = pubkeyHexLowercased.lowercased() + queue.async(flags: .barrier) { + if isBlocked { + self.cache.blockedNostrPubkeys.insert(key) + } else { + self.cache.blockedNostrPubkeys.remove(key) + } + self.saveIdentityCache() + } + } + + func getBlockedNostrPubkeys() -> Set { + queue.sync { cache.blockedNostrPubkeys } + } // MARK: - Ephemeral Session Management @@ -464,4 +488,4 @@ class SecureIdentityStateManager { return cache.verifiedFingerprints } } -} \ No newline at end of file +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 9857e674..7064ee6a 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -149,58 +149,75 @@ class CommandProcessor { let targetName = args.trimmingCharacters(in: .whitespaces) if targetName.isEmpty { - // List blocked users - guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else { - return .success(message: "no blocked peers") - } - + // List blocked users (mesh) and geohash (Nostr) blocks + let meshBlocked = chatViewModel?.blockedUsers ?? [] var blockedNicknames: [String] = [] if let peers = meshService?.getPeerNicknames() { for (peerID, nickname) in peers { if let fingerprint = meshService?.getFingerprint(for: peerID), - blockedUsers.contains(fingerprint) { + meshBlocked.contains(fingerprint) { blockedNicknames.append(nickname) } } } - - let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)" - : blockedNicknames.sorted().joined(separator: ", ") - return .success(message: "blocked peers: \(list)") + + // Geohash blocked names (prefer visible display names; fallback to #suffix) + let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) + var geoNames: [String] = [] + if let vm = chatViewModel { + let visible = vm.visibleGeohashPeople() + let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) }) + for pk in geoBlocked { + if let name = visibleIndex[pk.lowercased()] { + geoNames.append(name) + } else { + let suffix = String(pk.suffix(4)) + geoNames.append("anon#\(suffix)") + } + } + } + + let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ") + let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ") + return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)") } let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), - let fingerprint = meshService?.getFingerprint(for: peerID) else { - return .error(message: "cannot block \(nickname): not found or unable to verify identity") + if let peerID = chatViewModel?.getPeerIDForNickname(nickname), + let fingerprint = meshService?.getFingerprint(for: peerID) { + if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + return .success(message: "\(nickname) is already blocked") + } + // Block the user (mesh/noise identity) + if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { + identity.isBlocked = true + identity.isFavorite = false + SecureIdentityStateManager.shared.updateSocialIdentity(identity) + } else { + let blockedIdentity = SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: nickname, + trustLevel: .unknown, + isFavorite: false, + isBlocked: true, + notes: nil + ) + SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) + } + return .success(message: "blocked \(nickname). you will no longer receive messages from them") + } + // Mesh lookup failed; try geohash (Nostr) participant by display name + if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { + if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { + return .success(message: "\(nickname) is already blocked") + } + SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true) + return .success(message: "blocked \(nickname) in geohash chats") } - if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { - return .success(message: "\(nickname) is already blocked") - } - - // Block the user - if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) { - identity.isBlocked = true - identity.isFavorite = false - SecureIdentityStateManager.shared.updateSocialIdentity(identity) - } else { - let blockedIdentity = SocialIdentity( - fingerprint: fingerprint, - localPetname: nil, - claimedNickname: nickname, - trustLevel: .unknown, - isFavorite: false, - isBlocked: true, - notes: nil - ) - SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity) - } - - // The peerStateManager and SecureIdentityStateManager handle the blocking state - - return .success(message: "blocked \(nickname). you will no longer receive messages from them") + return .error(message: "cannot block \(nickname): not found or unable to verify identity") } private func handleUnblock(_ args: String) -> CommandResult { @@ -211,19 +228,23 @@ class CommandProcessor { let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName - guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), - let fingerprint = meshService?.getFingerprint(for: peerID) else { - return .error(message: "cannot unblock \(nickname): not found") + if let peerID = chatViewModel?.getPeerIDForNickname(nickname), + let fingerprint = meshService?.getFingerprint(for: peerID) { + if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { + return .success(message: "\(nickname) is not blocked") + } + SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) + return .success(message: "unblocked \(nickname)") } - - if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { - return .success(message: "\(nickname) is not blocked") + // Try geohash unblock + if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) { + if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) { + return .success(message: "\(nickname) is not blocked") + } + SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false) + return .success(message: "unblocked \(nickname) in geohash chats") } - - SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false) - // The SecureIdentityStateManager handles the unblocking state - - return .success(message: "unblocked \(nickname)") + return .error(message: "cannot unblock \(nickname): not found") } private func handleFavorite(_ args: String, add: Bool) -> CommandResult { diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 4047fcd1..64548e70 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -654,19 +654,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let wasReadBefore = self.sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage - let msg = BitchatMessage( - id: messageId, - sender: senderName, - content: pm.content, - timestamp: messageTimestamp, - isRelay: false, - originalSender: nil, - isPrivate: true, - recipientNickname: self.nickname, - senderPeerID: convKey, - mentions: nil, - deliveryStatus: .delivered(to: self.nickname, at: Date()) - ) + let msg = BitchatMessage( + id: messageId, + sender: senderName, + content: pm.content, + timestamp: messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: self.nickname, + senderPeerID: convKey, + mentions: nil, + deliveryStatus: .delivered(to: self.nickname, at: Date()) + ) + // Respect geohash blocks + if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { + return + } if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] } self.privateChats[convKey]?.append(msg) self.trimPrivateChatMessagesIfNeeded(for: convKey) @@ -1243,6 +1247,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let nick = nickTag[1] self.geoNicknames[event.pubkey.lowercased()] = nick } + // If this pubkey is blocked, skip mapping, participants, and timeline + if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } // Store mapping for geohash DM initiation let key16 = "nostr_" + String(event.pubkey.prefix(16)) self.nostrKeyMapping[key16] = event.pubkey @@ -1446,6 +1454,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var map = geoParticipants[gh] ?? [:] // Prune expired entries map = map.filter { $0.value >= cutoff } + // Remove blocked Nostr pubkeys + map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } geoParticipants[gh] = map // Build display list let people = map @@ -1478,7 +1488,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func visibleGeohashPeople() -> [GeoPerson] { guard let gh = currentGeohash else { return [] } let cutoff = Date().addingTimeInterval(-5 * 60) - let map = (geoParticipants[gh] ?? [:]).filter { $0.value >= cutoff } + let map = (geoParticipants[gh] ?? [:]) + .filter { $0.value >= cutoff } + .filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) } let people = map .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .sorted { $0.lastSeen > $1.lastSeen } @@ -1492,6 +1504,66 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return map.values.filter { $0 >= cutoff }.count } + // Geohash block helpers + @MainActor + func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { + return SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + } + @MainActor + func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + let hex = pubkeyHexLowercased.lowercased() + SecureIdentityStateManager.shared.setNostrBlocked(hex, isBlocked: true) + + // Remove from participants for all geohashes + for (gh, var map) in geoParticipants { + map.removeValue(forKey: hex) + geoParticipants[gh] = map + } + refreshGeohashPeople() + + // Remove their public messages from current geohash timeline and visible list + if let gh = currentGeohash { + if var arr = geoTimelines[gh] { + arr.removeAll { msg in + if let spid = msg.senderPeerID, spid.hasPrefix("nostr") { + if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } + } + return false + } + geoTimelines[gh] = arr + } + // Also filter currently bound messages if we are in geohash channel + switch activeChannel { + case .location: + messages.removeAll { msg in + if let spid = msg.senderPeerID, spid.hasPrefix("nostr") { + if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } + } + return false + } + default: + break + } + } + + // Remove geohash DM conversation if exists + let convKey = "nostr_" + String(hex.prefix(16)) + if privateChats[convKey] != nil { + privateChats.removeValue(forKey: convKey) + unreadPrivateMessages.remove(convKey) + } + + // Remove mapping keys pointing to this pubkey to avoid accidental resolution + for (k, v) in nostrKeyMapping where v.lowercased() == hex { nostrKeyMapping.removeValue(forKey: k) } + + addSystemMessage("blocked \(displayName) in geohash chats") + } + @MainActor + func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { + SecureIdentityStateManager.shared.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) + addSystemMessage("unblocked \(displayName) in geohash chats") + } + /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel. @MainActor func beginGeohashSampling(for geohashes: [String]) { @@ -1618,6 +1690,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return } + // Respect geohash blocks + if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: recipientHex) { + if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + privateChats[peerID]?[msgIdx].deliveryStatus = .failed(reason: "user is blocked") + } + addSystemMessage("cannot send message: user is blocked.") + return + } // Send via Nostr using per-geohash identity do { let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) @@ -4607,10 +4687,28 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func isMessageBlocked(_ message: BitchatMessage) -> Bool { if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { - return isPeerBlocked(peerID) + // Check mesh/known peers first + if isPeerBlocked(peerID) { return true } + // Check geohash (Nostr) blocks using mapping to full pubkey + if peerID.hasPrefix("nostr") { + if let full = nostrKeyMapping[peerID]?.lowercased() { + if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: full) { return true } + } + } + return false } return false } + + // MARK: - Geohash Nickname Resolution (for /block in geohash) + @MainActor + func nostrPubkeyForDisplayName(_ name: String) -> String? { + // Look up current visible geohash participants for an exact displayName match + for p in visibleGeohashPeople() { + if p.displayName == name { return p.id } + } + return nil + } /// Process action messages (hugs, slaps) into system messages private func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { @@ -4878,9 +4976,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } /// Handle incoming public message + @MainActor private func handlePublicMessage(_ message: BitchatMessage) { let finalMessage = processActionMessage(message) + // Drop if sender is blocked (covers geohash via Nostr pubkey mapping) + if isMessageBlocked(finalMessage) { return } + // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 30449cb6..514cd905 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -244,9 +244,18 @@ struct ContentView: View { } Button("BLOCK", role: .destructive) { - if let sender = selectedMessageSender { + // Prefer direct geohash block when we have a Nostr sender ID + #if os(iOS) + if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"), + let full = viewModel.fullNostrHex(forSenderPeerID: peerID), + let sender = selectedMessageSender { + viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender) + } else if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") } + #else + if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") } + #endif } Button("cancel", role: .cancel) {} diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 2227a78b..b7b4aea3 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -71,6 +71,15 @@ struct GeohashPeopleList: View { .foregroundColor(rowColor) } } + // Blocked indicator for geohash users + if let me = myHex, person.id != me { + if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) { + Image(systemName: "nosign") + .font(.system(size: 10)) + .foregroundColor(.red) + .help("Blocked in geochash") + } + } Spacer() } .padding(.horizontal) @@ -83,6 +92,18 @@ struct GeohashPeopleList: View { onTapPerson() } } + .contextMenu { + if let me = myHex, person.id == me { + EmptyView() + } else { + let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) + if blocked { + Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } + } else { + Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) } + } + } + } } } } diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 79941fc0..911761f5 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -75,6 +75,14 @@ struct MeshPeerList: View { } } + // Blocked indicator + if !isMe, viewModel.isPeerBlocked(peer.id) { + Image(systemName: "nosign") + .font(.system(size: 10)) + .foregroundColor(.red) + .help("Blocked") + } + if let icon = item.enc.icon, !isMe { Image(systemName: icon) .font(.system(size: 10))