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(