From 2f72d1a07c1574d5076135ffe426c647aacfdd8f Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 08:03:06 +0530 Subject: [PATCH 1/3] fix: refuse an ambiguous nickname instead of picking the first peer /msg, /block, and geohash DMs took the first map entry for a shared nickname (Cure53 BCH-01-003 / #589). Mentions already fail closed; use the same unique-or-nil rule, with the people-list #xxxx suffix to pick a specific peer. --- .../android/nostr/GeohashRepository.kt | 10 ++- .../com/bitchat/android/ui/ChatUIUtils.kt | 48 +++++++++++ .../com/bitchat/android/ui/ChatViewModel.kt | 2 +- .../bitchat/android/ui/CommandProcessor.kt | 81 +++++++++++-------- .../bitchat/android/ui/PrivateChatManager.kt | 2 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 66 +++++++++++++++ 6 files changed, 168 insertions(+), 41 deletions(-) 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 50c72cd4..4fc600b9 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -40,10 +40,12 @@ class GeohashRepository( @Synchronized fun findPubkeyByNickname(targetNickname: String): String? { - return geoNicknames.entries.firstOrNull { (_, nickname) -> - val base = nickname.split("#").firstOrNull() ?: nickname - base == targetNickname - }?.key + // Same unique-or-nil rule as mesh: colliding nicks must use the + // people-list suffix (last four hex characters of the pubkey). + return com.bitchat.android.ui.uniquePeerIDForNickname( + targetNickname, + geoNicknames, + ) { pubkey -> "#${pubkey.takeLast(4)}" } } @Synchronized diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 14c7879d..62ec665e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -342,6 +342,54 @@ fun splitSuffix(name: String): Pair { return Pair(name, "") } +/** + * Resolve a typed nickname to a mesh peer ID. + * + * An unsuffixed name is accepted only when it identifies exactly one peer. + * Colliding nicknames must be disambiguated with the `#xxxx` people-list suffix + * (first four hex characters of the peer ID), matching mention coloring. + */ +internal sealed class NicknameResolution { + data class Unique(val peerID: String) : NicknameResolution() + data object None : NicknameResolution() + data object Ambiguous : NicknameResolution() +} + +internal fun resolvePeerIDForNickname( + query: String, + nicknames: Map, + suffixOf: (String) -> String = { "#${it.take(4)}" }, +): NicknameResolution { + val target = query.trim().removePrefix("@") + if (target.isEmpty()) return NicknameResolution.None + + val (base, suffix) = splitSuffix(target) + val matches = linkedSetOf() + for ((peerID, nickname) in nicknames) { + val nick = nickname.trim() + if (nick.equals(target, ignoreCase = true)) { + matches.add(peerID) + continue + } + if (suffix.isNotEmpty() && nick.equals(base, ignoreCase = true)) { + if (suffixOf(peerID).equals(suffix, ignoreCase = true)) { + matches.add(peerID) + } + } + } + return when (matches.size) { + 0 -> NicknameResolution.None + 1 -> NicknameResolution.Unique(matches.first()) + else -> NicknameResolution.Ambiguous + } +} + +internal fun uniquePeerIDForNickname( + query: String, + nicknames: Map, + suffixOf: (String) -> String = { "#${it.take(4)}" }, +): String? = (resolvePeerIDForNickname(query, nicknames, suffixOf) as? NicknameResolution.Unique)?.peerID + /** * Build a case-insensitive mention-token lookup from canonical peer identities. * diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index f40549b6..b9a5712a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1075,7 +1075,7 @@ class ChatViewModel( // MARK: - Utility Functions fun getPeerIDForNickname(nickname: String): String? { - return mesh.getPeerNicknames().entries.find { it.value == nickname }?.key + return uniquePeerIDForNickname(nickname, mesh.getPeerNicknames()) } fun toggleFavorite(peerID: String) { 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 1cccfb19..6584422b 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -86,42 +86,53 @@ class CommandProcessor( private fun handleMessageCommand(parts: List, meshService: MeshService, viewModel: ChatViewModel?) { if (parts.size > 1) { val targetName = parts[1].removePrefix("@") - val peerID = getPeerIDForNickname(targetName, meshService) - - if (peerID != null) { - val success = privateChatManager.startPrivateChat(peerID, meshService) - - if (success) { - if (parts.size > 2) { - val messageContent = parts.drop(2).joinToString(" ") - val recipientNickname = getPeerNickname(peerID, meshService) - sendPrivateMessage( - messageContent, - peerID, - recipientNickname, - state.getNicknameValue(), - getMyPeerID(meshService), - meshService, - viewModel - ) - } else { - val systemMessage = BitchatMessage( - sender = "system", - content = "started private chat with $targetName", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) + when (val resolved = resolvePeerIDForNickname(targetName, meshService.getPeerNicknames())) { + is NicknameResolution.Unique -> { + val peerID = resolved.peerID + val success = privateChatManager.startPrivateChat(peerID, meshService) + + if (success) { + if (parts.size > 2) { + val messageContent = parts.drop(2).joinToString(" ") + val recipientNickname = getPeerNickname(peerID, meshService) + sendPrivateMessage( + messageContent, + peerID, + recipientNickname, + state.getNicknameValue(), + getMyPeerID(meshService), + meshService, + viewModel + ) + } else { + val systemMessage = BitchatMessage( + sender = "system", + content = "started private chat with $targetName", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + } } } - } else { - val systemMessage = BitchatMessage( - sender = "system", - content = "user '$targetName' not found. they may be offline or using a different nickname.", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) + NicknameResolution.Ambiguous -> { + val systemMessage = BitchatMessage( + sender = "system", + content = "nickname '$targetName' is used by more than one peer; use the #xxxx suffix from the people list.", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + } + NicknameResolution.None -> { + val systemMessage = BitchatMessage( + sender = "system", + content = "user '$targetName' not found. they may be offline or using a different nickname.", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(systemMessage) + } } } else { val systemMessage = BitchatMessage( @@ -627,7 +638,7 @@ class CommandProcessor( // MARK: - Utility Functions private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { - return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key + return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames()) } private fun getPeerNickname(peerID: String, meshService: MeshService): String { diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 12678625..0c09f171 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -577,7 +577,7 @@ class PrivateChatManager( // MARK: - Utility Functions private fun getPeerIDForNickname(nickname: String, meshService: MeshService): String? { - return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key + return uniquePeerIDForNickname(nickname, meshService.getPeerNicknames()) } private fun getPeerNickname(peerID: String, meshService: MeshService): String { diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 44377856..797947c5 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -310,6 +310,72 @@ class ChatUIUtilsTest { assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) } + @Test + fun `unsuffixed nickname is unique when only one peer uses it`() { + val resolved = resolvePeerIDForNickname( + "Alice", + mapOf("aabbccdd" to "alice") + ) + assertEquals(NicknameResolution.Unique("aabbccdd"), resolved) + } + + @Test + fun `unsuffixed nickname is refused when two peers share it`() { + val resolved = resolvePeerIDForNickname( + "alice", + mapOf( + "1111aaaa" to "alice", + "2222bbbb" to "alice", + ) + ) + assertEquals(NicknameResolution.Ambiguous, resolved) + assertEquals(null, uniquePeerIDForNickname("alice", mapOf( + "1111aaaa" to "alice", + "2222bbbb" to "alice", + ))) + } + + @Test + fun `hash suffix selects the colliding peer`() { + val nicknames = mapOf( + "1111aaaa" to "alice", + "2222bbbb" to "alice", + ) + assertEquals( + NicknameResolution.Unique("1111aaaa"), + resolvePeerIDForNickname("alice#1111", nicknames) + ) + assertEquals( + NicknameResolution.Unique("2222bbbb"), + resolvePeerIDForNickname("ALICE#2222", nicknames) + ) + } + + @Test + fun `unknown nickname resolves to none`() { + assertEquals( + NicknameResolution.None, + resolvePeerIDForNickname("bob", mapOf("1111aaaa" to "alice")) + ) + } + + @Test + fun `geohash last-four suffix selects the colliding pubkey`() { + val nicknames = mapOf( + "1111aaaa" to "alice", + "2222bbbb" to "alice", + ) + val lastFour: (String) -> String = { "#${it.takeLast(4)}" } + assertEquals( + NicknameResolution.Unique("1111aaaa"), + resolvePeerIDForNickname("alice#aaaa", nicknames, lastFour) + ) + assertEquals( + NicknameResolution.Ambiguous, + resolvePeerIDForNickname("alice", nicknames, lastFour) + ) + } + @Test fun `message without mentions has no background chips`() { val body = formatTextMessageBody( From b9dd5bfe6e17ecfd7ef1c5ef279439ce7e4dd9c0 Mon Sep 17 00:00:00 2001 From: Taksh Date: Tue, 25 Aug 2026 08:27:34 +0530 Subject: [PATCH 2/3] fix: show the same #xxxx mesh suffix the nickname resolver accepts The people list computed a collision but never rendered a suffix, so /msg alice refused while alice#1111 was not on the row. --- .../java/com/bitchat/android/ui/ChatScreen.kt | 4 ++- .../com/bitchat/android/ui/ChatUIUtils.kt | 36 +++++++++++++++++++ .../bitchat/android/ui/CommandProcessor.kt | 17 +++++++-- .../bitchat/android/ui/MeshPeerListSheet.kt | 4 +-- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 20 +++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..9326cda1 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -241,9 +241,11 @@ fun ChatScreen(viewModel: ChatViewModel) { displayName to PeerIdentity.nostr(person.id) } } else { + val duplicateNames = duplicateMeshBaseNames(peerNicknames) connectedPeers.mapNotNull { peerID -> peerNicknames[peerID]?.let { displayName -> - displayName to PeerIdentity.mesh(peerID) + disambiguatedMeshDisplayName(peerID, displayName, duplicateNames) to + PeerIdentity.mesh(peerID) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 62ec665e..6f1ca826 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -390,6 +390,42 @@ internal fun uniquePeerIDForNickname( suffixOf: (String) -> String = { "#${it.take(4)}" }, ): String? = (resolvePeerIDForNickname(query, nicknames, suffixOf) as? NicknameResolution.Unique)?.peerID +/** First four hex characters of a mesh peer ID, matching mention coloring. */ +internal fun meshPeerSuffix(peerID: String): String = "#${peerID.take(4)}" + +internal fun duplicateMeshBaseNames(nicknames: Map): Set = + nicknames.values + .groupingBy { splitSuffix(it).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * People-list / autocomplete suffix for a mesh peer. + * + * Mesh nicknames are stored unsuffixed, unlike some geohash announcements, so a collision + * has to derive `#xxxx` from the peer ID. Preserve an already-embedded suffix if present. + */ +internal fun meshIdentitySuffix( + peerID: String, + displayName: String, + showHashSuffix: Boolean, +): String { + if (!showHashSuffix || displayName == "You") return "" + val announcedSuffix = splitSuffix(displayName).second + return announcedSuffix.ifEmpty { meshPeerSuffix(peerID) } +} + +internal fun disambiguatedMeshDisplayName( + peerID: String, + displayName: String, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + meshIdentitySuffix(peerID, displayName, showSuffix) +} + /** * Build a case-insensitive mention-token lookup from canonical peer identities. * 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 6584422b..2c83d658 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -573,9 +573,15 @@ class CommandProcessor( when (val selectedChannel = viewModel.selectedLocationChannel.value) { is com.bitchat.android.geohash.ChannelID.Mesh, null -> { - // Mesh channel: use Bluetooth mesh peer nicknames + // Mesh channel: use Bluetooth mesh peer nicknames, with the same + // #xxxx suffix the people list shows when two peers share a name. val peerNicknames = meshService.getPeerNicknames() - peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } + val myPeerID = meshService.myPeerID + val duplicateNames = duplicateMeshBaseNames(peerNicknames) + peerNicknames.mapNotNull { (peerID, name) -> + if (peerID == myPeerID) null + else disambiguatedMeshDisplayName(peerID, name, duplicateNames) + } } is com.bitchat.android.geohash.ChannelID.Location -> { @@ -606,7 +612,12 @@ class CommandProcessor( } else { // Fallback to mesh peers if no viewModel available val peerNicknames = meshService.getPeerNicknames() - peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] } + val myPeerID = meshService.myPeerID + val duplicateNames = duplicateMeshBaseNames(peerNicknames) + peerNicknames.mapNotNull { (peerID, name) -> + if (peerID == myPeerID) null + else disambiguatedMeshDisplayName(peerID, name, duplicateNames) + } } val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt) diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c..9f5fc20f 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -1480,9 +1480,9 @@ private fun PeerItem( isDirect = isDirect ) // Split display name for hashtag suffix support (iOS-compatible) - val (baseNameRaw, suffixRaw) = splitSuffix(displayName) + val (baseNameRaw, _) = splitSuffix(displayName) val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" + val suffix = meshIdentitySuffix(peerID, displayName, showHashSuffix) val isMe = displayName == "You" || peerID == currentNickname // Get consistent peer color (iOS-compatible) diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt index 797947c5..649beb87 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -351,6 +351,26 @@ class ChatUIUtilsTest { ) } + @Test + fun `mesh people-list suffix is the first four hex characters of the peer id`() { + val nicknames = mapOf( + "1111aaaa" to "alice", + "2222bbbb" to "alice", + "ccccdddd" to "bob", + ) + val duplicates = duplicateMeshBaseNames(nicknames) + assertEquals(setOf("alice"), duplicates) + assertEquals("alice#1111", disambiguatedMeshDisplayName("1111aaaa", "alice", duplicates)) + assertEquals("alice#2222", disambiguatedMeshDisplayName("2222bbbb", "alice", duplicates)) + assertEquals("bob", disambiguatedMeshDisplayName("ccccdddd", "bob", duplicates)) + assertEquals( + "#1111", + meshIdentitySuffix("1111aaaa", "alice", showHashSuffix = true) + ) + assertEquals("", meshIdentitySuffix("1111aaaa", "alice", showHashSuffix = false)) + assertEquals("", meshIdentitySuffix("1111aaaa", "You", showHashSuffix = true)) + } + @Test fun `unknown nickname resolves to none`() { assertEquals(