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(