diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index 4792102a1..a4e615f79 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -69,6 +69,29 @@ class GeohashRepository( return matches.singleOrNull()?.key } + /** Resolve a block command within the displayed channel, including anonymous senders. */ + fun findPubkeyForGeohashBlock(geohash: String, targetNickname: String): String? { + val base = targetNickname.substringBefore('#') + val suffix = targetNickname.substringAfter('#', missingDelimiterValue = "") + if (base.isEmpty() || ('#' in targetNickname && suffix.length != 4)) return null + + fun matches(pubkey: String, name: String): Boolean = + name.substringBefore('#').equals(base, ignoreCase = true) && + (suffix.isEmpty() || pubkey.endsWith(suffix, ignoreCase = true)) + + val snapshot = participantStore.snapshots.value + val candidates = mutableSetOf() + for (pubkey in snapshot.participants[geohash].orEmpty().keys) { + if (matches(pubkey, snapshot.nicknames[pubkey] ?: "anon")) candidates.add(pubkey) + } + // A person can age out of presence while their message is still visible. + for (message in state.getChannelMessagesValue()["geo:$geohash"].orEmpty()) { + val pubkey = message.senderNostrPubkey?.lowercase() ?: continue + if (matches(pubkey, message.originalSender ?: message.sender)) candidates.add(pubkey) + } + return candidates.singleOrNull() + } + @Synchronized fun findPubkeyByShortId(shortId: String): String? { // First check cached nicknames (fastest) diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index 1cccfb193..01650a458 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -20,7 +20,7 @@ class CommandProcessor( // Available commands list private val baseCommands = listOf( - CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"), + CommandSuggestion("/block", listOf("/ban"), "[nickname]", "block or list blocked peers"), CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"), CommandSuggestion("/clear", emptyList(), null, "clear chat messages"), CommandSuggestion("/hug", emptyList(), "", "send someone a warm hug"), @@ -46,7 +46,7 @@ class CommandProcessor( "/w" -> handleWhoCommand(meshService, viewModel) "/clear" -> handleClearCommand() "/pass" -> handlePassCommand(parts, myPeerID) - "/block" -> handleBlockCommand(parts, meshService) + "/block", "/ban" -> handleBlockCommand(parts, meshService, viewModel) "/unblock" -> handleUnblockCommand(parts, meshService) "/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel) "/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel) @@ -256,9 +256,15 @@ class CommandProcessor( } } - private fun handleBlockCommand(parts: List, meshService: MeshService) { - if (parts.size > 1) { - val targetName = parts[1].removePrefix("@") + private fun handleBlockCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { + val targetName = parts.drop(1).firstOrNull { it.isNotBlank() }?.removePrefix("@") + if (!targetName.isNullOrEmpty()) { + if (state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location && + state.getSelectedPrivateChatPeerValue() == null + ) { + viewModel?.blockUserInGeohash(targetName) + return + } privateChatManager.blockPeerByNickname(targetName, meshService) } else { // List blocked users diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 48c8b6056..15db5b13c 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -285,30 +285,28 @@ class GeohashViewModel( fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() fun blockUserInGeohash(targetNickname: String, pubkeyHex: String? = null) { + val selected = state.selectedLocationChannel.value as? com.bitchat.android.geohash.ChannelID.Location + val geohash = selected?.channel?.geohash val pubkey = pubkeyHex?.takeIf { it.isNotBlank() }?.lowercase() - ?: repo.findPubkeyByNickname(targetNickname) - if (pubkey != null) { + ?: geohash?.let { repo.findPubkeyForGeohashBlock(it, targetNickname) } + val feedback = if (pubkey != null) { dataManager.addGeohashBlockedUser(pubkey) messageManager.removeGeohashMessagesFromSender(pubkey) // Refresh people list and counts to remove blocked entry immediately repo.refreshGeohashPeople() repo.updateReactiveParticipantCounts() - val sysMsg = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "blocked $targetNickname in geohash channels", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(sysMsg) + "blocked $targetNickname in geohash channels" } else { - val sysMsg = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "user '$targetNickname' not found in current geohash", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(sysMsg) + "peer '$targetNickname' not found or ambiguous in this geohash" } + val message = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = feedback, + timestamp = Date(), + isRelay = false + ) + if (geohash != null) messageManager.addChannelMessage("geo:$geohash", message) + else messageManager.addMessage(message) } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 2e52b7507..e46ce6653 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -19,6 +19,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import java.util.Date @@ -142,6 +143,25 @@ class CommandProcessorTest { assertTrue(locallyRead.contains(message.id)) } + @Test + fun `block and ban in a geohash use the geohash block handler`() { + chatState.setSelectedLocationChannel( + ChannelID.Location(GeohashChannel(GeohashChannelLevel.PROVINCE, "0000")) + ) + val viewModel: ChatViewModel = mock() + for (command in listOf("/block", "/ban")) { + assertTrue(commandProcessor.processCommand( + command = "$command @anon#a1b2", + meshService = meshService, + myPeerID = "self", + onSendMessage = { _, _, _ -> error("blocking must not publish a message") }, + viewModel = viewModel + )) + } + verify(viewModel, Mockito.times(2)).blockUserInGeohash("anon#a1b2", null) + verify(meshService, Mockito.never()).getPeerNicknames() + } + @Test fun `pay feedback is added to active geohash channel`() { val geohash = "u0nd" diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/GeohashNicknameLookupTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashNicknameLookupTest.kt index 20480152a..a8796c00a 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/GeohashNicknameLookupTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashNicknameLookupTest.kt @@ -2,6 +2,7 @@ package com.bitchat.android.nostr import android.app.Application import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.model.BitchatMessage import com.bitchat.android.services.AppStateStore import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.DataManager @@ -16,6 +17,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.util.Date @RunWith(RobolectricTestRunner::class) class GeohashNicknameLookupTest { @@ -26,13 +28,15 @@ class GeohashNicknameLookupTest { private val bob = "c".repeat(60) + "3333" private val store = GeohashParticipantStore { 0L } private lateinit var scope: CoroutineScope + private lateinit var state: ChatState private lateinit var repo: GeohashRepository @Before fun setUp() { AppStateStore.clear() scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) - repo = GeohashRepository(application, ChatState(scope), DataManager(application), scope, store) + state = ChatState(scope) + repo = GeohashRepository(application, state, DataManager(application), scope, store) store.cacheNickname(firstAlice, "fixture-alice") store.cacheNickname(secondAlice, "fixture-alice") store.cacheNickname(bob, "fixture-bob") @@ -65,4 +69,32 @@ class GeohashNicknameLookupTest { assertNull(repo.findPubkeyByNickname("fixture-alice#3333")) assertNull(repo.findPubkeyByNickname("anon")) } + + @Test + fun `block target is restricted to the selected geohash and fails closed on collisions`() { + store.updateParticipant("0000", firstAlice, 0) + store.updateParticipant("0001", secondAlice, 0) + + assertEquals(firstAlice, repo.findPubkeyForGeohashBlock("0000", "fixture-alice")) + assertNull(repo.findPubkeyForGeohashBlock("0000", "fixture-alice#2222")) + + store.updateParticipant("0000", secondAlice, 0) + assertNull(repo.findPubkeyForGeohashBlock("0000", "fixture-alice")) + assertEquals(secondAlice, repo.findPubkeyForGeohashBlock("0000", "fixture-alice#2222")) + } + + @Test + fun `anonymous sender can be blocked from a retained chat message without presence`() { + val pubkey = "d".repeat(60) + "a1b2" + state.setChannelMessages(mapOf("geo:0000" to listOf(BitchatMessage( + sender = "anon", + content = "fixture message", + timestamp = Date(0), + originalSender = "anon#a1b2", + senderNostrPubkey = pubkey + )))) + + assertEquals(pubkey, repo.findPubkeyForGeohashBlock("0000", "anon#A1B2")) + assertNull(repo.findPubkeyForGeohashBlock("0001", "anon#a1b2")) + } }