Skip to content

Nickname Collision Handling and Mention Coloring#638

Open
callebtc wants to merge 5 commits intomainfrom
fix/bch-01-003
Open

Nickname Collision Handling and Mention Coloring#638
callebtc wants to merge 5 commits intomainfrom
fix/bch-01-003

Conversation

@callebtc
Copy link
Collaborator

Summary

This PR implements comprehensive nickname collision handling and identity-aware mention coloring to prevent impersonation and improve chat clarity.

Key Changes

  • Nickname Disambiguation: Automatically appends a short ID suffix (last 4 chars of Peer ID) to nicknames in the peer list, chat timeline, and notifications when a collision is detected (e.g., alice vs alice#1234).
  • Identity-Aware Coloring: Refactored ChatUIUtils to resolve mentions back to Peer IDs. Mentions are now rendered in the actual color of the mentioned user rather than the sender's color.
  • Secure Slash Commands: Updated /msg, /block, and /unblock to detect ambiguous nicknames and prompt the user to use the unique #suffix identifier.
  • Performance Optimization: Optimized the message rendering pipeline by passing stable nickname snapshots, reducing redundant service calls during scrolling.
  • Self-Collision Support: The system now detects when another user adopts your nickname and disambiguates their display name while keeping yours clean.
  • Bug Fixes: Fixed a ClassCastException in the sidebar and made the mention autocomplete deterministic.

Fixes #589

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4dced3399

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +639 to +641
// 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t mark mesh connected when only self is present

Adding the local peer to PeerManager on startup means the peer list is never empty while the service is active. MeshDelegateHandler.didUpdatePeerList sets isConnected based on peers.isNotEmpty(), so with this change the app reports a connected mesh even when no other devices are around. That breaks UI/logic that assumes “connected” implies at least one remote peer (status indicators, /who output, etc.). Consider keeping self out of the active peer list or filtering it before computing connection state.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also causes issues like being able to open a chat with yourself, this is best solved at data layer level by filtering getActivePeerIDs() in PeerManager.kt to exclude self. This will solve it for isConnected and all other cases.
return peers.filterValues { it.isConnected && it.id != myPeerID }

    /**
     * Get list of active peer IDs
     */
    fun getActivePeerIDs(): List<String> {
        return peers.filterValues { it.isConnected && it.id != myPeerID  }
            .keys
            .toList()
            .sorted()
    }

Copy link
Contributor

@moehamade moehamade left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using /mention , it correctly shows the ambiguous users in the commands, but when using any other command like /m or /block etc.. I think we should do the same in that case.

Critical fixes would be to filter self from active peers and double check displayed hashes if it's similar with iOS.

Medium improvements would be to fix ambiguous names caching with using something like:
private val ambiguousNicknamesCache = ConcurrentHashMap.newKeySet<String>()

Rest are optional improvements but nice to be done.

Comment on lines +639 to +641
// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also causes issues like being able to open a chat with yourself, this is best solved at data layer level by filtering getActivePeerIDs() in PeerManager.kt to exclude self. This will solve it for isConnected and all other cases.
return peers.filterValues { it.isConnected && it.id != myPeerID }

    /**
     * Get list of active peer IDs
     */
    fun getActivePeerIDs(): List<String> {
        return peers.filterValues { it.isConnected && it.id != myPeerID  }
            .keys
            .toList()
            .sorted()
    }

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be made more efficient, every time a peer joins/leaves, all visible messages recompose and each one calls:
peers.values.count { it.nickname.trim().equals(nick, ignoreCase = true) } > 1
Consider using a Set to cache ambiguous names instead of calculating this for every message.
Not really a big issue because only 20 messages are shown on the screen anyway, but it's not optimal.

Assuming we have 50 peers:

Without cache:

  • 20 visible messages × 50 peers = 1,000 string comparisons per peer change

With cache:

  • Rebuild cache: 50 iterations (once)
  • Lookups: 20 Set checks (O(1) each)
  • Total: 70 operations
  • Savings: 93%

Comment on lines 170 to +176
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enhancement (Optional): Compose UI should be as dumb as possible, we use viewModels as brains to control what is usually displayed on the screen, or helper functions for formatting UI stuff.

I would personally move this to a viewModel

// In ChatViewModel.kt
fun handleMessageLongPress(message: BitchatMessage) {
      val disambiguated = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
          meshService.getDisambiguatedNickname(message.senderPeerID)
      } else {
          message.sender
      }
        ```
}

// In ChatScreen.kt
onMessageLongPress = viewModel::handleMessageLongPress

or use an MVI (model view intent) approach that sends events/ intents from screen UI to the viewModel and proceed this event in the viewModel:

// in ChatScreen
onMessageLongPress = { onAction(MessageLongPressed(message)) }

// in ChatViewmodel
fun onAction(action: ChatActions){
   when(action){
      is MessageLongPressed -> { /** Do some work **/ }
    }
}

Comment on lines +308 to +332
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)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed there's a lot of code duplication that can be extracted into a helper function (we're currently doing same checks and creating same messages), and content should be extracted to string resources for translations.

Comment on lines +375 to +387
fun resolvePeerIDFromDisplayName(displayName: String, nicknames: Map<String, String>): 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
}
Copy link
Contributor

@moehamade moehamade Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it's correct,I think this is wrong, that's what I got from debugging:

base = "anon6250"
suffix = "#d116"
id = "d1169b550ebfb205"
nick = "anon6250"
displayName = "anon6250#d116"
displayName = "anon6250#d116"

should create:
"$nick#${peerID.takeLast(4)}" // Should be "anon6250#b205"

it's using:
"$nick#${peerID.take(4)}" // Created "anon6250#d116"

maybe consider using
takeLast instead of endsWith or double checking what we're using on iOS vs Android.

Open from 3 devices (1 iOS and 1 Android at least), and check the 3rd device hashes from Phone 1 and Phone 2, I think they are displaying different hashes, maybe one platform is taking first 4 and the other is taking last 4?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BCH-01-003: Android Nickname Handling Enables User Impersonation (High)

2 participants