diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 474c04a5a..314124c3c 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -47,7 +47,7 @@ class BluetoothMeshService(private val context: Context) { // My peer identification - derived from persisted Noise identity fingerprint (first 16 hex chars) val myPeerID: String = encryptionService.getIdentityFingerprint().take(16) - private val peerManager = PeerManager() + private val peerManager = PeerManager().apply { myPeerID = this@BluetoothMeshService.myPeerID } private val fragmentManager = FragmentManager() private val securityManager = SecurityManager(encryptionService, myPeerID) private val storeForwardManager = StoreForwardManager() @@ -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 = peerManager.getAllPeerNicknames() + + /** + * Get disambiguated peer nickname (nickname#suffix if collisions exist) + */ + fun getDisambiguatedNickname(peerID: String): String = peerManager.getDisambiguatedNickname(peerID) /** * Get peer RSSI values diff --git a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt index 536a9b2af..0fd517822 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PeerManager.kt @@ -89,6 +89,9 @@ class PeerManager { // Callback to check if a peer is directly connected (injected by BluetoothMeshService) var isPeerDirectlyConnected: ((String) -> Boolean)? = null + // My own Peer ID (to treat specially in disambiguation and cleanup) + var myPeerID: String? = null + // Coroutines private val managerScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -207,22 +210,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() - 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 +301,18 @@ 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 + + // Suffix is appended if ambiguous, UNLESS this is our own Peer ID + return if (isAmbiguous && peerID != myPeerID) "$nick#${peerID.takeLast(4)}" else nick + } + /** * Get all peer nicknames */ @@ -433,7 +433,9 @@ class PeerManager { private fun cleanupStalePeers() { val now = System.currentTimeMillis() - val peersToRemove = peers.filterValues { (now - it.lastSeen) > stalePeerTimeoutMs } + val peersToRemove = peers.filterValues { + it.id != myPeerID && (now - it.lastSeen) > stalePeerTimeoutMs + } .keys .toList() 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 822606c2a..e37536687 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -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 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 0d22a0d50..2e387c428 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -130,6 +130,7 @@ fun ChatScreen(viewModel: ChatViewModel) { ) // Messages area - takes up available space, will compress when keyboard appears + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() MessagesList( messages = displayMessages, currentUserNickname = nickname, @@ -137,6 +138,7 @@ fun ChatScreen(viewModel: ChatViewModel) { modifier = Modifier.weight(1f), forceScrollToBottom = forceScrollToBottom, onScrolledUpChanged = { isUp -> isScrolledUp = isUp }, + peerNicknames = peerNicknames, onNicknameClick = { fullSenderName -> // Single click - mention user in text input val currentText = messageText.text @@ -167,9 +169,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 }, 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 82db6b649..30eb28413 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -44,26 +44,43 @@ fun formatMessageAsAnnotatedString( currentUserNickname: String, meshService: BluetoothMeshService, colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + peerNicknames: Map = emptyMap() ): AnnotatedString { val builder = AnnotatedString.Builder() val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f // Determine if this message was sent by self - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = if (message.senderPeerID != null) { + message.senderPeerID == meshService.myPeerID + } else { + 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) } else { - getPeerColor(message, isDark) + colorForPeer(message.senderPeerID, message.sender, isDark) } // 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 +102,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 ) @@ -117,10 +134,9 @@ fun formatMessageAsAnnotatedString( builder.pop() // Message content with iOS-style hashtag and mention highlighting - appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark) + appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark, meshService, peerNicknames) // iOS-style timestamp at the END (smaller, grey) - // Timestamp (and optional PoW badge) builder.pushStyle(SpanStyle( color = Color.Gray.copy(alpha = 0.7f), fontSize = (BASE_FONT_SIZE - 4).sp @@ -139,7 +155,7 @@ fun formatMessageAsAnnotatedString( builder.pushStyle(SpanStyle( color = Color.Gray, fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + fontStyle = FontStyle.Italic )) builder.append("* ${message.content} *") builder.pop() @@ -164,18 +180,29 @@ fun formatMessageHeaderAnnotatedString( currentUserNickname: String, meshService: BluetoothMeshService, colorScheme: ColorScheme, - timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()), + peerNicknames: Map = emptyMap() ): AnnotatedString { val builder = AnnotatedString.Builder() val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = if (message.senderPeerID != null) { + message.senderPeerID == meshService.myPeerID + } else { + message.sender == currentUserNickname || + message.sender.startsWith("$currentUserNickname#") + } if (message.sender != "system") { - val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark) - val (baseName, suffix) = splitSuffix(message.sender) + // Resolve disambiguated sender name if it's a mesh message + val senderDisplayName = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) { + meshService.getDisambiguatedNickname(message.senderPeerID) + } else { + message.sender + } + + val baseColor = if (isSelf) Color(0xFFFF9500) else colorForPeer(message.senderPeerID, message.sender, isDark) + val (baseName, suffix) = splitSuffix(senderDisplayName) // "<@" builder.pushStyle(SpanStyle( @@ -198,7 +225,7 @@ fun formatMessageHeaderAnnotatedString( if (!isSelf) { builder.addStringAnnotation( tag = "nickname_click", - annotation = (message.originalSender ?: message.sender), + annotation = senderDisplayName, start = nicknameStart, end = nicknameEnd ) @@ -240,7 +267,7 @@ fun formatMessageHeaderAnnotatedString( builder.pushStyle(SpanStyle( color = Color.Gray, fontSize = (BASE_FONT_SIZE - 2).sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + fontStyle = FontStyle.Italic )) builder.append("* ${message.content} *") builder.pop() @@ -260,23 +287,26 @@ fun formatMessageHeaderAnnotatedString( * Avoids orange (~30°) reserved for self messages */ fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color { + return colorForPeer(message.senderPeerID, message.sender, isDark) +} + +/** + * Generate consistent peer color based on peer ID or nickname + */ +fun colorForPeer(peerID: String?, nickname: String, isDark: Boolean): Color { // Create seed from peer identifier (prioritizing stable keys) val seed = when { - message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> { - // For Nostr peers, use the full key if available, otherwise the peer ID - "nostr:${message.senderPeerID.lowercase()}" + peerID?.startsWith("nostr:") == true || peerID?.startsWith("nostr_") == true -> { + "nostr:${peerID.lowercase()}" } - message.senderPeerID?.length == 16 -> { - // For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID - "noise:${message.senderPeerID.lowercase()}" + peerID?.length == 16 -> { + "noise:${peerID.lowercase()}" } - message.senderPeerID?.length == 64 -> { - // This is already a stable Noise key - "noise:${message.senderPeerID.lowercase()}" + peerID?.length == 64 -> { + "noise:${peerID.lowercase()}" } else -> { - // Fallback to sender name - message.sender.lowercase() + nickname.lowercase() } } @@ -328,6 +358,34 @@ fun splitSuffix(name: String): Pair { return Pair(name, "") } +/** + * Truncate nickname if it's too long for UI display + */ +fun truncateNickname(nickname: String): String { + return if (nickname.length > 20) { + nickname.take(18) + ".." + } else { + nickname + } +} + +/** + * Resolve a display name (with potential #suffix) back to a Peer ID + */ +fun resolvePeerIDFromDisplayName(displayName: String, nicknames: Map): String? { + val (base, suffix) = splitSuffix(displayName) + + return nicknames.entries.find { (id, nick) -> + if (suffix.isNotEmpty()) { + // Match nickname and Peer ID ending + nick.equals(base, ignoreCase = true) && id.endsWith(suffix.removePrefix("#"), ignoreCase = true) + } else { + // Match nickname only + nick.equals(displayName, ignoreCase = true) + } + }?.key +} + /** * iOS-style content formatting with proper hashtag and mention handling */ @@ -338,7 +396,9 @@ private fun appendIOSFormattedContent( currentUserNickname: String, baseColor: Color, isSelf: Boolean, - isDark: Boolean + isDark: Boolean, + meshService: BluetoothMeshService, + peerNicknames: Map = emptyMap() ) { // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() @@ -370,7 +430,6 @@ private fun appendIOSFormattedContent( } // Add standalone geohash matches (e.g., "#9q") that are not part of another word - // We use MessageSpecialParser to find exact ranges; then merge with existing ranges avoiding overlaps val geoMatches = MessageSpecialParser.findStandaloneGeohashes(content) for (gm in geoMatches) { val range = gm.start until gm.endExclusive @@ -388,7 +447,7 @@ private fun appendIOSFormattedContent( } } - // Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering + // Remove generic hashtag matches that overlap with detected geohash ranges fun rangesOverlap(a: IntRange, b: IntRange): Boolean { return a.first < b.last && a.last > b.first } @@ -398,7 +457,6 @@ private fun appendIOSFormattedContent( val iterator = allMatches.listIterator() while (iterator.hasNext()) { val (range, type) = iterator.next() - // Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs val overlapsGeo = geoRanges.any { rangesOverlap(range, it) } val overlapsUrl = urlRanges.any { rangesOverlap(range, it) } if ((type == "hashtag" && overlapsGeo) || (type == "geohash" && overlapsUrl)) iterator.remove() @@ -410,6 +468,9 @@ private fun appendIOSFormattedContent( var lastEnd = 0 val isMentioned = mentions?.contains(currentUserNickname) == true + // Use provided map if available, else query service once + val nicknameMap = if (peerNicknames.isNotEmpty()) peerNicknames else meshService.getPeerNicknames() + for ((range, type) in allMatches) { // Add text before match if (lastEnd < range.first) { @@ -421,7 +482,6 @@ private fun appendIOSFormattedContent( fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal )) if (isMentioned) { - // Make entire message bold if user is mentioned builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) builder.append(beforeText) builder.pop() @@ -436,13 +496,19 @@ private fun appendIOSFormattedContent( val matchText = content.substring(range.first, range.last + 1) when (type) { "mention" -> { - // iOS-style mention with hashtag suffix support val mentionWithoutAt = matchText.removePrefix("@") val (mBase, mSuffix) = splitSuffix(mentionWithoutAt) - // Check if this mention targets current user - val isMentionToMe = mBase == currentUserNickname - val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor + // Resolve PeerID to check strict identity match + val resolvedID = resolvePeerIDFromDisplayName(mentionWithoutAt, nicknameMap) + val isMentionToMe = resolvedID == meshService.myPeerID + + // Resolve mention color based on mentioned user's actual color + val mentionColor = if (isMentionToMe) { + Color(0xFFFF9500) // Orange for mentions to self + } else { + colorForPeer(resolvedID, mentionWithoutAt, isDark) + } // "@" symbol builder.pushStyle(SpanStyle( @@ -453,7 +519,7 @@ private fun appendIOSFormattedContent( builder.append("@") builder.pop() - // Base name (truncate for rendering) + // Base name builder.pushStyle(SpanStyle( color = mentionColor, fontSize = BASE_FONT_SIZE.sp, @@ -474,7 +540,6 @@ private fun appendIOSFormattedContent( } } "hashtag" -> { - // Render general hashtags like normal content builder.pushStyle(SpanStyle( color = baseColor, fontSize = BASE_FONT_SIZE.sp, @@ -491,7 +556,6 @@ private fun appendIOSFormattedContent( } else -> { if (type == "geohash") { - // Style geohash in blue, underlined, and add click annotation builder.pushStyle(SpanStyle( color = Color(0xFF007AFF), fontSize = BASE_FONT_SIZE.sp, @@ -502,15 +566,9 @@ private fun appendIOSFormattedContent( builder.append(matchText) val end = builder.length val geohash = matchText.removePrefix("#").lowercase() - builder.addStringAnnotation( - tag = "geohash_click", - annotation = geohash, - start = start, - end = end - ) + builder.addStringAnnotation(tag = "geohash_click", annotation = geohash, start = start, end = end) builder.pop() } else if (type == "url") { - // Style URL in blue, underlined, and add click annotation with the raw text builder.pushStyle(SpanStyle( color = Color(0xFF007AFF), fontSize = BASE_FONT_SIZE.sp, @@ -520,15 +578,9 @@ private fun appendIOSFormattedContent( val start = builder.length builder.append(matchText) val end = builder.length - builder.addStringAnnotation( - tag = "url_click", - annotation = matchText, - start = start, - end = end - ) + builder.addStringAnnotation(tag = "url_click", annotation = matchText, start = start, end = end) builder.pop() } else { - // Fallback: treat as normal text builder.pushStyle(SpanStyle( color = baseColor, fontSize = BASE_FONT_SIZE.sp, 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 5456ca4d6..733dbdd17 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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) 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 499b39261..005f94cb3 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -80,43 +80,56 @@ class CommandProcessor( private fun handleMessageCommand(parts: List, 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, 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, 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,38 @@ 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) : PeerResolutionResult() + } + + private fun resolvePeerIDForNickname(targetName: String, meshService: BluetoothMeshService): PeerResolutionResult { + val nicknames = meshService.getPeerNicknames() + + val (baseName, suffixWithHash) = splitSuffix(targetName) + + if (suffixWithHash.isNotEmpty()) { + val suffix = suffixWithHash.removePrefix("#") + 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 { diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 9c7d776f6..150401ba4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -251,7 +251,8 @@ private fun formatMessageAsAnnotatedStringWithoutTimestamp( currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + peerNicknames = emptyMap() // Can't easily get map here without refactoring AnimatedMessageDisplay ) // Find and remove the timestamp and PoW badge at the end diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index d27719c76..6162a9997 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -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 ) 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 e5688a4e9..5274e671e 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -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) @@ -838,7 +839,8 @@ fun PrivateChatSheet( onNicknameClick = { /* handle mention */ }, onMessageLongPress = { /* handle long press */ }, onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) }, - onImageClick = { _, _, _ -> /* handle image click */ } + onImageClick = { _, _, _ -> /* handle image click */ }, + peerNicknames = peerNicknames ) HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f)) diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 986c8c638..5e41b1671 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -65,7 +65,8 @@ fun MessagesList( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + peerNicknames: Map = emptyMap() ) { val listState = rememberLazyListState() @@ -126,7 +127,8 @@ fun MessagesList( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, - onImageClick = onImageClick + onImageClick = onImageClick, + peerNicknames = peerNicknames ) } } @@ -142,7 +144,8 @@ fun MessageItem( onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, onCancelTransfer: ((BitchatMessage) -> Unit)? = null, - onImageClick: ((String, List, Int) -> Unit)? = null + onImageClick: ((String, List, Int) -> Unit)? = null, + peerNicknames: Map = emptyMap() ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } @@ -171,6 +174,7 @@ fun MessageItem( onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick, + peerNicknames = peerNicknames, modifier = Modifier .weight(1f) .padding(end = endPad) @@ -208,6 +212,7 @@ fun MessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, + peerNicknames: Map = emptyMap(), modifier: Modifier = Modifier ) { // Image special rendering @@ -223,6 +228,7 @@ fun MessageItem( onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, onImageClick = onImageClick, + peerNicknames = peerNicknames, modifier = modifier ) return @@ -239,6 +245,7 @@ fun MessageItem( onNicknameClick = onNicknameClick, onMessageLongPress = onMessageLongPress, onCancelTransfer = onCancelTransfer, + peerNicknames = peerNicknames, modifier = modifier ) return @@ -263,7 +270,8 @@ fun MessageItem( currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + peerNicknames = peerNicknames ) val haptic = LocalHapticFeedback.current var headerLayout by remember { mutableStateOf(null) } @@ -371,13 +379,17 @@ fun MessageItem( currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + peerNicknames = peerNicknames ) // Check if this message was sent by self to avoid click interactions on own nickname - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") + val isSelf = if (message.senderPeerID != null) { + message.senderPeerID == meshService.myPeerID + } else { + message.sender == currentUserNickname || + message.sender.startsWith("$currentUserNickname#") + } val haptic = LocalHapticFeedback.current val context = LocalContext.current 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 af2882bbe..b5ffbbb9e 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt index 264d9d6ce..eb2fc72af 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt @@ -36,6 +36,7 @@ fun AudioMessageItem( onNicknameClick: ((String) -> Unit)?, onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, + peerNicknames: Map = emptyMap(), modifier: Modifier = Modifier ) { val path = message.content.trim() @@ -55,7 +56,8 @@ fun AudioMessageItem( currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + peerNicknames = peerNicknames ) val haptic = LocalHapticFeedback.current var headerLayout by remember { mutableStateOf(null) } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 2310cd5b4..d7ff5deec 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -46,6 +46,7 @@ fun ImageMessageItem( onMessageLongPress: ((BitchatMessage) -> Unit)?, onCancelTransfer: ((BitchatMessage) -> Unit)?, onImageClick: ((String, List, Int) -> Unit)?, + peerNicknames: Map = emptyMap(), modifier: Modifier = Modifier ) { val path = message.content.trim() @@ -55,7 +56,8 @@ fun ImageMessageItem( currentUserNickname = currentUserNickname, meshService = meshService, colorScheme = colorScheme, - timeFormatter = timeFormatter + timeFormatter = timeFormatter, + peerNicknames = peerNicknames ) val haptic = LocalHapticFeedback.current var headerLayout by remember { mutableStateOf(null) }