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
+117 -15
View File
@@ -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