wip: nickname disambiguation

This commit is contained in:
callebtc
2026-01-17 01:19:48 +07:00
parent e3056d70c0
commit a81bf4489d
10 changed files with 231 additions and 133 deletions
@@ -636,6 +636,10 @@ class BluetoothMeshService(private val context: Context) {
if (connectionManager.startServices()) {
isActive = true
// Add ourselves to peer manager for collision detection
val myNickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
peerManager.addOrUpdatePeer(myPeerID, myNickname)
// Start periodic announcements for peer discovery and connectivity
sendPeriodicBroadcastAnnounce()
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
@@ -697,6 +701,14 @@ class BluetoothMeshService(private val context: Context) {
return reusable
}
/**
* Update our own nickname in peer manager and announce it
*/
fun updateSelfNickname(newNickname: String) {
peerManager.addOrUpdatePeer(myPeerID, newNickname)
sendBroadcastAnnounce()
}
/**
* Send public message
*/
@@ -1175,6 +1187,11 @@ class BluetoothMeshService(private val context: Context) {
* Get peer nicknames
*/
fun getPeerNicknames(): Map<String, String> = peerManager.getAllPeerNicknames()
/**
* Get disambiguated peer nickname (nickname#suffix if collisions exist)
*/
fun getDisambiguatedNickname(peerID: String): String = peerManager.getDisambiguatedNickname(peerID)
/**
* Get peer RSSI values
@@ -207,22 +207,7 @@ class PeerManager {
fun addOrUpdatePeer(peerID: String, nickname: String): Boolean {
if (peerID == "unknown") return false
// Clean up stale peer IDs with the same nickname (exact same logic as iOS)
val now = System.currentTimeMillis()
val stalePeerIDs = mutableListOf<String>()
peers.forEach { (existingPeerID, info) ->
if (info.nickname == nickname && existingPeerID != peerID) {
val wasRecentlySeen = (now - info.lastSeen) < 10000
if (!wasRecentlySeen) {
stalePeerIDs.add(existingPeerID)
}
}
}
// Remove stale peer IDs
stalePeerIDs.forEach { stalePeerID ->
removePeer(stalePeerID, notifyDelegate = false)
}
// Check if this is a new peer announcement
val isFirstAnnounce = !announcedPeers.contains(peerID)
@@ -313,6 +298,16 @@ class PeerManager {
return peers[peerID]?.nickname
}
/**
* Get disambiguated peer nickname (nickname#suffix if collisions exist)
*/
fun getDisambiguatedNickname(peerID: String): String {
val info = peers[peerID] ?: return peerID
val nick = info.nickname.trim()
val isAmbiguous = peers.values.count { it.nickname.trim().equals(nick, ignoreCase = true) } > 1
return if (isAmbiguous) "$nick#${peerID.takeLast(4)}" else nick
}
/**
* Get all peer nicknames
*/
@@ -37,10 +37,25 @@ class GeohashRepository(
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
fun findPubkeyByNickname(targetNickname: String): String? {
return geoNicknames.entries.firstOrNull { (_, nickname) ->
val base = nickname.split("#").firstOrNull() ?: nickname
base == targetNickname
}?.key
val parts = targetNickname.split("#")
val baseName = parts[0]
val suffix = if (parts.size > 1) parts[1] else null
if (suffix != null) {
// Exact match attempt: nickname + pubkey suffix
return geoNicknames.entries.find { (id, nick) ->
val base = nick.split("#").firstOrNull() ?: nick
base.equals(baseName, ignoreCase = true) && id.endsWith(suffix, ignoreCase = true)
}?.key
} else {
// No suffix provided. Check for matches.
val matches = geoNicknames.entries.filter { (_, nick) ->
val base = nick.split("#").firstOrNull() ?: nick
base.equals(baseName, ignoreCase = true)
}
// If only one match, return it. If multiple, it's ambiguous (return null or handle)
return if (matches.size == 1) matches.first().key else null
}
}
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
@@ -167,9 +167,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
// Use disambiguated name so actions like /block work with suffixes if collisions exist
val disambiguated = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
viewModel.meshService.getDisambiguatedNickname(message.senderPeerID)
} else {
message.sender
}
selectedUserForSheet = disambiguated
selectedMessageForSheet = message
showUserSheet = true
},
@@ -53,8 +53,21 @@ fun formatMessageAsAnnotatedString(
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") {
// Resolve disambiguated sender name if it's a mesh message
val senderDisplayName = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
val disambiguated = meshService.getDisambiguatedNickname(message.senderPeerID)
// If it returned the raw peerID because the peer is offline, fall back to message.sender (nickname at time of send)
if (disambiguated == message.senderPeerID && message.sender != message.senderPeerID) {
message.sender
} else {
disambiguated
}
} else {
message.sender
}
// Get base color for this peer (iOS-style color assignment)
val baseColor = if (isSelf) {
Color(0xFFFF9500) // Orange for self (iOS orange)
@@ -63,7 +76,7 @@ fun formatMessageAsAnnotatedString(
}
// Split sender into base name and hashtag suffix
val (baseName, suffix) = splitSuffix(message.sender)
val (baseName, suffix) = splitSuffix(senderDisplayName)
// Sender prefix "<@"
builder.pushStyle(SpanStyle(
@@ -85,11 +98,11 @@ fun formatMessageAsAnnotatedString(
builder.append(truncatedBase)
val nicknameEnd = builder.length
// Add click annotation for nickname (store canonical sender name with hash if available)
// Add click annotation for nickname (store disambiguated name for mentions)
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
annotation = senderDisplayName,
start = nicknameStart,
end = nicknameEnd
)
@@ -174,8 +187,21 @@ fun formatMessageHeaderAnnotatedString(
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") {
// Resolve disambiguated sender name if it's a mesh message
val senderDisplayName = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
val disambiguated = meshService.getDisambiguatedNickname(message.senderPeerID)
// If it returned the raw peerID because the peer is offline, fall back to message.sender (nickname at time of send)
if (disambiguated == message.senderPeerID && message.sender != message.senderPeerID) {
message.sender
} else {
disambiguated
}
} else {
message.sender
}
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
val (baseName, suffix) = splitSuffix(message.sender)
val (baseName, suffix) = splitSuffix(senderDisplayName)
// "<@"
builder.pushStyle(SpanStyle(
@@ -198,7 +224,7 @@ fun formatMessageHeaderAnnotatedString(
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
annotation = senderDisplayName,
start = nicknameStart,
end = nicknameEnd
)
@@ -317,7 +317,7 @@ class ChatViewModel(
fun setNickname(newNickname: String) {
state.setNickname(newNickname)
dataManager.saveNickname(newNickname)
meshService.sendBroadcastAnnounce()
meshService.updateSelfNickname(newNickname)
}
/**
@@ -519,7 +519,7 @@ class ChatViewModel(
}
}
// Send private message
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
val recipientNickname = meshService.getDisambiguatedNickname(selectedPeer)
privateChatManager.sendPrivateMessage(
content,
selectedPeer,
@@ -581,10 +581,6 @@ class ChatViewModel(
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
fun toggleFavorite(peerID: String) {
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
privateChatManager.toggleFavorite(peerID)
@@ -80,43 +80,56 @@ class CommandProcessor(
private fun handleMessageCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
val peerID = getPeerIDForNickname(targetName, meshService)
val resolution = resolvePeerIDForNickname(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)
privateChatManager.sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
// This would trigger the actual mesh service send
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
when (resolution) {
is PeerResolutionResult.Found -> {
val peerID = resolution.peerID
val success = privateChatManager.startPrivateChat(peerID, meshService)
if (success) {
if (parts.size > 2) {
val messageContent = parts.drop(2).joinToString(" ")
val recipientNickname = getPeerNickname(peerID, meshService)
privateChatManager.sendPrivateMessage(
messageContent,
peerID,
recipientNickname,
state.getNicknameValue(),
getMyPeerID(meshService)
) { content, peerIdParam, recipientNicknameParam, messageId ->
// This would trigger the actual mesh service send
sendPrivateMessageVia(meshService, content, peerIdParam, recipientNicknameParam, messageId)
}
} 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 = "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)
is PeerResolutionResult.Ambiguous -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "multiple users found with nickname '$targetName'. please use one of: ${resolution.candidates.joinToString(", ")}",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
is PeerResolutionResult.NotFound -> {
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(
@@ -251,7 +264,31 @@ class CommandProcessor(
private fun handleBlockCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.blockPeerByNickname(targetName, meshService)
val resolution = resolvePeerIDForNickname(targetName, meshService)
when (resolution) {
is PeerResolutionResult.Found -> {
privateChatManager.blockPeer(resolution.peerID, meshService)
}
is PeerResolutionResult.Ambiguous -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "multiple users found with nickname '$targetName'. please use one of: ${resolution.candidates.joinToString(", ")}",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
is PeerResolutionResult.NotFound -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
} else {
// List blocked users
val blockedInfo = privateChatManager.listBlockedUsers()
@@ -268,7 +305,31 @@ class CommandProcessor(
private fun handleUnblockCommand(parts: List<String>, meshService: BluetoothMeshService) {
if (parts.size > 1) {
val targetName = parts[1].removePrefix("@")
privateChatManager.unblockPeerByNickname(targetName, meshService)
val resolution = resolvePeerIDForNickname(targetName, meshService)
when (resolution) {
is PeerResolutionResult.Found -> {
privateChatManager.unblockPeer(resolution.peerID, meshService)
}
is PeerResolutionResult.Ambiguous -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "multiple users found with nickname '$targetName'. please use one of: ${resolution.candidates.joinToString(", ")}",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
is PeerResolutionResult.NotFound -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found.",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
}
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
@@ -448,7 +509,12 @@ class CommandProcessor(
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: use Bluetooth mesh peer nicknames
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
val nicknames = meshService.getPeerNicknames().toMap() // Take a stable snapshot
val myPeerID = meshService.myPeerID
nicknames.filter { it.key != myPeerID }.map { (id, nick) ->
val isAmbiguous = nicknames.values.count { it == nick } > 1
if (isAmbiguous) "$nick#${id.takeLast(4)}" else nick
}
}
is com.bitchat.android.geohash.ChannelID.Location -> {
@@ -503,12 +569,39 @@ class CommandProcessor(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
private sealed class PeerResolutionResult {
data class Found(val peerID: String) : PeerResolutionResult()
object NotFound : PeerResolutionResult()
data class Ambiguous(val candidates: List<String>) : PeerResolutionResult()
}
private fun resolvePeerIDForNickname(targetName: String, meshService: BluetoothMeshService): PeerResolutionResult {
val nicknames = meshService.getPeerNicknames()
val parts = targetName.split("#")
val baseName = parts[0]
val suffix = if (parts.size > 1) parts[1] else null
if (suffix != null) {
val peerID = nicknames.entries.find { (id, nick) ->
nick.equals(baseName, ignoreCase = true) && id.takeLast(4).equals(suffix, ignoreCase = true)
}?.key
return if (peerID != null) PeerResolutionResult.Found(peerID) else PeerResolutionResult.NotFound
} else {
val matches = nicknames.entries.filter { it.value.equals(baseName, ignoreCase = true) }
return when {
matches.isEmpty() -> PeerResolutionResult.NotFound
matches.size == 1 -> PeerResolutionResult.Found(matches.first().key)
else -> {
val candidates = matches.map { "${it.value}#${it.key.takeLast(4)}" }
PeerResolutionResult.Ambiguous(candidates)
}
}
}
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
return meshService.getDisambiguatedNickname(peerID)
}
private fun getMyPeerID(meshService: BluetoothMeshService): String {
@@ -54,8 +54,8 @@ class MeshDelegateHandler(
// Show notification with enhanced information - now includes senderPeerID
message.senderPeerID?.let { senderPeerID ->
// Use nickname if available, fall back to sender or senderPeerID
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
// Use disambiguated nickname for notifications
val senderNickname = getMeshService().getDisambiguatedNickname(senderPeerID)
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
notificationManager.showPrivateMessageNotification(
senderPeerID = senderPeerID,
@@ -250,10 +250,11 @@ class MeshDelegateHandler(
val isMention = checkForMeshMention(message.content, currentNickname)
if (isMention) {
android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from ${message.sender}")
val senderNickname = message.senderPeerID?.let { getMeshService().getDisambiguatedNickname(it) } ?: message.sender
android.util.Log.d("MeshDelegateHandler", "🔔 Triggering mesh mention notification from $senderNickname")
notificationManager.showMeshMentionNotification(
senderNickname = message.sender,
senderNickname = senderNickname,
messageContent = message.content,
senderPeerID = message.senderPeerID
)
@@ -34,6 +34,7 @@ import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.util.toHexString
/**
@@ -367,7 +368,7 @@ fun PeopleSection(
// Offline favorites (exclude ones mapped to connected)
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.toHexString()
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (!isMappedToConnected) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
@@ -435,7 +436,7 @@ fun PeopleSection(
// Append offline favorites we actively favorite (and not currently connected)
offlineFavorites.forEach { fav ->
val favPeerID = fav.peerNoisePublicKey.joinToString("") { b -> "%02x".format(b) }
val favPeerID = fav.peerNoisePublicKey.toHexString()
// If any connected peer maps to this noise key, skip showing the offline entry
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
if (isMappedToConnected) return@forEach
@@ -560,7 +561,7 @@ private fun PeerItem(
// Split display name for hashtag suffix support (iOS-compatible)
val (baseNameRaw, suffixRaw) = splitSuffix(displayName)
val baseName = truncateNickname(baseNameRaw)
val suffix = if (showHashSuffix) suffixRaw else ""
val suffix = if (showHashSuffix && suffixRaw.isEmpty()) "#${peerID.takeLast(4)}" else if (showHashSuffix) suffixRaw else ""
val isMe = displayName == "You" || peerID == currentNickname
// Get consistent peer color (iOS-compatible)
@@ -193,7 +193,7 @@ class PrivateChatManager(
if (fingerprint != null) {
dataManager.addBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val peerNickname = meshService.getDisambiguatedNickname(peerID)
val systemMessage = BitchatMessage(
sender = "system",
content = "blocked user $peerNickname",
@@ -217,7 +217,7 @@ class PrivateChatManager(
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
dataManager.removeBlockedUser(fingerprint)
val peerNickname = getPeerNickname(peerID, meshService)
val peerNickname = meshService.getDisambiguatedNickname(peerID)
val systemMessage = BitchatMessage(
sender = "system",
content = "unblocked user $peerNickname",
@@ -230,52 +230,6 @@ class PrivateChatManager(
return false
}
fun blockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
return blockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun unblockPeerByNickname(targetName: String, meshService: BluetoothMeshService): Boolean {
val peerID = getPeerIDForNickname(targetName, meshService)
if (peerID != null) {
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
if (fingerprint != null && dataManager.isUserBlocked(fingerprint)) {
return unblockPeer(peerID, meshService)
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' is not blocked",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
} else {
val systemMessage = BitchatMessage(
sender = "system",
content = "user '$targetName' not found",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(systemMessage)
return false
}
}
fun listBlockedUsers(): String {
val blockedCount = dataManager.blockedUsers.size
return if (blockedCount == 0) {
@@ -413,12 +367,8 @@ class PrivateChatManager(
// MARK: - Utility Functions
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
return meshService.getPeerNicknames().entries.find { it.value == nickname }?.key
}
private fun getPeerNickname(peerID: String, meshService: BluetoothMeshService): String {
return meshService.getPeerNicknames()[peerID] ?: peerID
return meshService.getDisambiguatedNickname(peerID)
}
// MARK: - Consolidation