Fix/geohash blocks (#483)

* fix(geo-block): enable block/unblock for geohash users and enforce blocks\n\n- Add persisted set of blocked Nostr pubkeys in SecureIdentityStateManager\n- Check Nostr blocks for incoming messages (public + geo DMs)\n- Prevent sending geo DMs to blocked users\n- Extend /block and /unblock to resolve geohash display names to pubkeys and act accordingly\n- Improve /block list to show geohash blocks (visible names or #suffix)

* fix(geo-block): enforce blocks on geohash public and DM receive; add block/unblock actions to geohash people list

* fix: mark handlePublicMessage as @MainActor to call isMessageBlocked safely

* ui(block): show blocked indicator (nosign icon) next to blocked peers in mesh and geohash lists

* fix(geo-block): early-drop blocked pubkeys on geohash receive; filter blocked users from participants list

* fix(geo-block): purge existing geohash messages/DMs on block; block directly from chat using Nostr sender ID when available

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-08-22 18:44:11 +02:00
committed by GitHub
co-authored by jack
parent b2504a7ff5
commit dadc896ed8
7 changed files with 255 additions and 67 deletions
+4 -1
View File
@@ -149,6 +149,9 @@ struct IdentityCache: Codable {
// Last interaction timestamps (privacy: optional) // Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:] var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
} }
@@ -222,4 +225,4 @@ enum ConnectionQuality {
} }
// MARK: - Migration Support // MARK: - Migration Support
// Removed LegacyFavorite - no longer needed // Removed LegacyFavorite - no longer needed
@@ -335,6 +335,30 @@ class SecureIdentityStateManager {
self.saveIdentityCache() 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<String> {
queue.sync { cache.blockedNostrPubkeys }
}
// MARK: - Ephemeral Session Management // MARK: - Ephemeral Session Management
@@ -464,4 +488,4 @@ class SecureIdentityStateManager {
return cache.verifiedFingerprints return cache.verifiedFingerprints
} }
} }
} }
+70 -49
View File
@@ -149,58 +149,75 @@ class CommandProcessor {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty { if targetName.isEmpty {
// List blocked users // List blocked users (mesh) and geohash (Nostr) blocks
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else { let meshBlocked = chatViewModel?.blockedUsers ?? []
return .success(message: "no blocked peers")
}
var blockedNicknames: [String] = [] var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() { if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers { for (peerID, nickname) in peers {
if let fingerprint = meshService?.getFingerprint(for: peerID), if let fingerprint = meshService?.getFingerprint(for: peerID),
blockedUsers.contains(fingerprint) { meshBlocked.contains(fingerprint) {
blockedNicknames.append(nickname) blockedNicknames.append(nickname)
} }
} }
} }
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)" // Geohash blocked names (prefer visible display names; fallback to #suffix)
: blockedNicknames.sorted().joined(separator: ", ") let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
return .success(message: "blocked peers: \(list)") 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 let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else { let fingerprint = meshService?.getFingerprint(for: peerID) {
return .error(message: "cannot block \(nickname): not found or unable to verify identity") if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// Block the user (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 .error(message: "cannot block \(nickname): not found or unable to verify identity")
return .success(message: "\(nickname) is already blocked")
}
// Block the user
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
// The peerStateManager and SecureIdentityStateManager handle the blocking state
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
} }
private func handleUnblock(_ args: String) -> CommandResult { private func handleUnblock(_ args: String) -> CommandResult {
@@ -211,19 +228,23 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname), if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else { let fingerprint = meshService?.getFingerprint(for: peerID) {
return .error(message: "cannot unblock \(nickname): not found") if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)")
} }
// Try geohash unblock
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) { if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
return .success(message: "\(nickname) is not blocked") 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")
} }
return .error(message: "cannot unblock \(nickname): not found")
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
// The SecureIdentityStateManager handles the unblocking state
return .success(message: "unblocked \(nickname)")
} }
private func handleFavorite(_ args: String, add: Bool) -> CommandResult { private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
+117 -15
View File
@@ -654,19 +654,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let wasReadBefore = self.sentReadReceipts.contains(messageId) let wasReadBefore = self.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
let msg = BitchatMessage( let msg = BitchatMessage(
id: messageId, id: messageId,
sender: senderName, sender: senderName,
content: pm.content, content: pm.content,
timestamp: messageTimestamp, timestamp: messageTimestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: self.nickname, recipientNickname: self.nickname,
senderPeerID: convKey, senderPeerID: convKey,
mentions: nil, mentions: nil,
deliveryStatus: .delivered(to: self.nickname, at: Date()) 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] = [] } if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] }
self.privateChats[convKey]?.append(msg) self.privateChats[convKey]?.append(msg)
self.trimPrivateChatMessagesIfNeeded(for: convKey) self.trimPrivateChatMessagesIfNeeded(for: convKey)
@@ -1243,6 +1247,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
let nick = nickTag[1] let nick = nickTag[1]
self.geoNicknames[event.pubkey.lowercased()] = nick 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 // Store mapping for geohash DM initiation
let key16 = "nostr_" + String(event.pubkey.prefix(16)) let key16 = "nostr_" + String(event.pubkey.prefix(16))
self.nostrKeyMapping[key16] = event.pubkey self.nostrKeyMapping[key16] = event.pubkey
@@ -1446,6 +1454,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
var map = geoParticipants[gh] ?? [:] var map = geoParticipants[gh] ?? [:]
// Prune expired entries // Prune expired entries
map = map.filter { $0.value >= cutoff } map = map.filter { $0.value >= cutoff }
// Remove blocked Nostr pubkeys
map = map.filter { !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: $0.key) }
geoParticipants[gh] = map geoParticipants[gh] = map
// Build display list // Build display list
let people = map let people = map
@@ -1478,7 +1488,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
func visibleGeohashPeople() -> [GeoPerson] { func visibleGeohashPeople() -> [GeoPerson] {
guard let gh = currentGeohash else { return [] } guard let gh = currentGeohash else { return [] }
let cutoff = Date().addingTimeInterval(-5 * 60) 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 let people = map
.map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) }
.sorted { $0.lastSeen > $1.lastSeen } .sorted { $0.lastSeen > $1.lastSeen }
@@ -1492,6 +1504,66 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
return map.values.filter { $0 >= cutoff }.count 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. /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel.
@MainActor @MainActor
func beginGeohashSampling(for geohashes: [String]) { func beginGeohashSampling(for geohashes: [String]) {
@@ -1618,6 +1690,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
return 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 // Send via Nostr using per-geohash identity
do { do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
@@ -4607,10 +4687,28 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor @MainActor
private func isMessageBlocked(_ message: BitchatMessage) -> Bool { private func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { 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 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 /// Process action messages (hugs, slaps) into system messages
private func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { private func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
@@ -4878,9 +4976,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
} }
/// Handle incoming public message /// Handle incoming public message
@MainActor
private func handlePublicMessage(_ message: BitchatMessage) { private func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = processActionMessage(message) 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) // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system)
let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true
+10 -1
View File
@@ -244,9 +244,18 @@ struct ContentView: View {
} }
Button("BLOCK", role: .destructive) { 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)") viewModel.sendMessage("/block \(sender)")
} }
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
} }
Button("cancel", role: .cancel) {} Button("cancel", role: .cancel) {}
+21
View File
@@ -71,6 +71,15 @@ struct GeohashPeopleList: View {
.foregroundColor(rowColor) .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() Spacer()
} }
.padding(.horizontal) .padding(.horizontal)
@@ -83,6 +92,18 @@ struct GeohashPeopleList: View {
onTapPerson() 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) }
}
}
}
} }
} }
} }
+8
View File
@@ -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 { if let icon = item.enc.icon, !isMe {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: 10)) .font(.system(size: 10))