From 5c8f03ce18f0eda6d7b6e72b21966926692a0779 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:33:05 +0200 Subject: [PATCH] fix mention colors --- .../bitchat/android/model/BitchatMessage.kt | 11 +- .../android/nostr/GeohashMessageHandler.kt | 1 + .../nostr/NostrDirectMessageHandler.kt | 4 +- .../java/com/bitchat/android/ui/ChatScreen.kt | 33 +++++ .../com/bitchat/android/ui/ChatUIUtils.kt | 67 +++++++++- .../com/bitchat/android/ui/ChatViewModel.kt | 12 +- .../bitchat/android/ui/CommandProcessor.kt | 15 ++- .../bitchat/android/ui/GeohashPeopleList.kt | 58 ++++++-- .../bitchat/android/ui/GeohashViewModel.kt | 4 +- .../com/bitchat/android/ui/InputComponents.kt | 11 +- .../bitchat/android/ui/MeshPeerListSheet.kt | 6 +- .../bitchat/android/ui/MessageComponents.kt | 13 ++ .../com/bitchat/android/ui/PeerColorSeed.kt | 33 ----- .../com/bitchat/android/ui/PeerIdentity.kt | 71 ++++++++++ .../bitchat/android/ui/theme/PeerColors.kt | 8 +- .../com/bitchat/android/ui/ChatUIUtilsTest.kt | 124 ++++++++++++++++-- .../android/ui/GeohashPresenceGroupingTest.kt | 47 +++++++ 17 files changed, 430 insertions(+), 88 deletions(-) delete mode 100644 app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index 8e1731b1..528a269e 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -69,7 +69,15 @@ data class BitchatMessage( val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, val deliveryStatus: DeliveryStatus? = null, - val powDifficulty: Int? = null + val powDifficulty: Int? = null, + /** + * Full canonical Nostr public key supplied by the local Nostr bridge. + * + * This is local identity metadata, not part of the Bitchat binary wire format. It lets UI + * surfaces color the sender by the same stable key while [senderPeerID] remains available for + * mesh IDs and private-chat routing aliases. + */ + val senderNostrPubkey: String? = null ) : Parcelable { /** @@ -355,4 +363,3 @@ data class BitchatMessage( } } - diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 1c4896f3..1fc802d9 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -96,6 +96,7 @@ class GeohashMessageHandler( isRelay = false, originalSender = repo.displayNameForNostrPubkey(pubkey), senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, mentions = null, channel = "#$subscribedGeohash", powDifficulty = try { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 9d8ae8af..7a49057d 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -148,6 +148,7 @@ class NostrDirectMessageHandler( isPrivate = true, recipientNickname = state.getNicknameValue(), senderPeerID = conversationID, + senderNostrPubkey = senderPubkey, deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) ) @@ -201,7 +202,8 @@ class NostrDirectMessageHandler( isRelay = false, isPrivate = true, recipientNickname = state.getNicknameValue(), - senderPeerID = conversationID + senderPeerID = conversationID, + senderNostrPubkey = senderPubkey ) Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)") withContext(Dispatchers.Main) { 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 60fd7d4a..85ea78bf 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -62,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme val messages by viewModel.messages.collectAsStateWithLifecycle() val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() @@ -197,6 +199,33 @@ fun ChatScreen(viewModel: ChatViewModel) { } } + val mentionPeerIdentities = remember( + displayMessages, + currentChannel, + selectedLocationChannel, + connectedPeers, + peerNicknames, + geohashPeople, + ) { + val knownPeers = if ( + currentChannel == null && selectedLocationChannel is ChannelID.Location + ) { + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) + geohashPeople.mapNotNull { person -> + if (isUnannouncedNickname(person.displayName)) return@mapNotNull null + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + displayName to PeerIdentity.nostr(person.id) + } + } else { + connectedPeers.mapNotNull { peerID -> + peerNicknames[peerID]?.let { displayName -> + displayName to PeerIdentity.mesh(peerID) + } + } + } + buildMentionPeerIdentityMap(displayMessages, knownPeers) + } + // Determine whether to show media buttons (only hide in geohash location chats) val showMediaButtons = when { currentChannel != null -> true @@ -238,6 +267,7 @@ fun ChatScreen(viewModel: ChatViewModel) { messages = displayMessages, currentUserNickname = nickname, meshService = viewModel.meshServiceFacade, + mentionPeerIdentities = mentionPeerIdentities, modifier = Modifier.fillMaxSize(), conversationKey = conversationKey, contentPadding = PaddingValues( @@ -350,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) { commandSuggestions = commandSuggestions, showMentionSuggestions = showMentionSuggestions, mentionSuggestions = mentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onCommandSuggestionClick = { suggestion: CommandSuggestion -> val commandText = viewModel.selectCommandSuggestion(suggestion) messageText = TextFieldValue( @@ -558,6 +589,7 @@ fun ChatInputSection( commandSuggestions: List, showMentionSuggestions: Boolean, mentionSuggestions: List, + mentionPeerIdentities: Map = emptyMap(), onCommandSuggestionClick: (CommandSuggestion) -> Unit, onMentionSuggestionClick: (String) -> Unit, selectedPrivatePeer: String?, @@ -624,6 +656,7 @@ fun ChatInputSection( Column { MentionSuggestionsBox( suggestions = displayedMentionSuggestions, + mentionPeerIdentities = mentionPeerIdentities, onSuggestionClick = onMentionSuggestionClick, modifier = Modifier.fillMaxWidth() ) 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 14676d07..19d52706 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -10,7 +10,7 @@ import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatPalette import com.bitchat.android.ui.theme.ChatVisualTokens -import com.bitchat.android.ui.theme.colorForPeerSeed +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.* @@ -61,7 +61,7 @@ fun formatTextMessageSender( val senderColor = if (isSelf) { palette.accentOrange } else { - colorForPeerSeed(peerColorSeedForMessage(message), palette) + colorForPeer(peerIdentityForMessage(message), palette) } val senderWeight = FontWeight.SemiBold val (baseName, suffix) = splitSuffix(message.sender) @@ -193,6 +193,7 @@ fun formatTextMessageBody( palette: BitchatPalette, contentColor: Color, linkColor: Color, + mentionPeerIdentities: Map = emptyMap(), timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()), includeTimestamp: Boolean = true ): AnnotatedString { @@ -205,6 +206,7 @@ fun formatTextMessageBody( palette = palette, contentColor = contentColor, linkColor = linkColor, + mentionPeerIdentities = mentionPeerIdentities, ) if (includeTimestamp) { @@ -267,7 +269,7 @@ fun formatMessageHeaderAnnotatedString( val baseColor = if (isSelf) { palette.accentOrange } else { - colorForPeerSeed(peerColorSeedForMessage(message), palette) + colorForPeer(peerIdentityForMessage(message), palette) } val (baseName, suffix) = splitSuffix(message.sender) @@ -326,6 +328,54 @@ fun splitSuffix(name: String): Pair { return Pair(name, "") } +/** + * Build a case-insensitive mention-token lookup from canonical peer identities. + * + * Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when + * it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention + * as the wrong person. + */ +internal fun buildMentionPeerIdentityMap( + messages: List, + knownPeers: List> = emptyList(), +): Map { + val candidates = linkedMapOf>() + + fun add(displayName: String, identity: PeerIdentity) { + val normalizedName = displayName.trim().removePrefix("@") + if (normalizedName.isEmpty()) return + + val (baseName, suffix) = splitSuffix(normalizedName) + val exactKey = normalizedName.lowercase(Locale.ROOT) + candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity) + + if (suffix.isNotEmpty()) { + val baseKey = baseName.lowercase(Locale.ROOT) + candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity) + } + } + + messages + .asSequence() + .filterNot { it.sender == "system" } + .forEach { add(it.sender, peerIdentityForMessage(it)) } + knownPeers.forEach { (displayName, identity) -> add(displayName, identity) } + + return candidates.mapNotNull { (token, identities) -> + identities.singleOrNull()?.let { token to it } + }.toMap() +} + +internal fun resolveMentionPeerIdentity( + mention: String, + mentionPeerIdentities: Map, +): PeerIdentity? { + val mentionWithoutAt = mention.trim().removePrefix("@") + val baseName = splitSuffix(mentionWithoutAt).first + return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)] + ?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)] +} + /** * A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport * may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as @@ -356,6 +406,7 @@ private fun appendIOSFormattedContent( palette: BitchatPalette, contentColor: Color, linkColor: Color, + mentionPeerIdentities: Map, ) { // iOS-style patterns: allow optional '#abcd' suffix in mentions val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex() @@ -462,9 +513,13 @@ private fun appendIOSFormattedContent( val mentionColor = if (isMentionToMe) { palette.accentOrange } else { - // Tint by the *mentioned* peer so a given name looks identical everywhere. - colorForPeerSeed( - PeerColorSeed(mentionWithoutAt.lowercase(Locale.ROOT)), + val identity = resolveMentionPeerIdentity( + mentionWithoutAt, + mentionPeerIdentities, + ) + ?: PeerIdentity.nickname(mentionWithoutAt) + colorForPeer( + identity, palette ) } 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 4c6d5cdc..b0ed2ac3 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1190,17 +1190,17 @@ class ChatViewModel( } } - // MARK: - iOS-Compatible Color System + // MARK: - Canonical peer identities /** - * Get consistent color for a mesh peer by ID (iOS-compatible) + * Return the stable identity used by every UI surface to color a mesh peer. */ - fun peerColorSeedForMeshPeer(peerID: String): PeerColorSeed = meshPeerColorSeed(peerID) + fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID) /** - * Get consistent color for a Nostr pubkey (iOS-compatible) + * Return the stable identity used by every UI surface to color a Nostr peer. */ - fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = - geohashViewModel.peerColorSeedForNostrPubkey(pubkeyHex) + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex) } 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 4ac9344a..34b205af 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -457,11 +457,20 @@ class CommandProcessor( // Location channel: use geohash participants with collision-resistant suffixes val geohashPeople = viewModel.geohashPeople.value val currentNickname = state.getNicknameValue() + val duplicateNames = duplicateGeohashBaseNames(geohashPeople) geohashPeople.mapNotNull { person -> - val displayName = person.displayName - // Exclude self from suggestions - if (displayName.startsWith("${currentNickname}#")) { + val baseName = splitSuffix(person.displayName).first + val hasNicknameCollision = + baseName.lowercase(Locale.ROOT) in duplicateNames + val displayName = disambiguatedGeohashDisplayName(person, duplicateNames) + // A unique local nickname can be excluded directly. If it collides, the + // nickname alone cannot identify which row is self, so keep the suffixed + // rows rather than accidentally hiding the other user. + if ( + !hasNicknameCollision && + baseName.equals(currentNickname, ignoreCase = true) + ) { null } else { displayName diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 8f65bbb1..0cf88b74 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.painterResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BitchatFontFamily -import com.bitchat.android.ui.theme.colorForPeerSeed +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.R import com.bitchat.android.ui.theme.LocalBitchatPalette import java.util.* @@ -91,13 +91,8 @@ fun GeohashPeopleList( val teleportedPersonIds = remember(sections.teleportedIn) { sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) } } - val baseNameCounts = remember(displayedPeople) { - buildMap { - displayedPeople.forEach { person -> - val baseName = splitSuffix(person.displayName).first - put(baseName, (get(baseName) ?: 0) + 1) - } - } + val duplicateBaseNames = remember(displayedPeople) { + duplicateGeohashBaseNames(displayedPeople) } Column(modifier = modifier) { @@ -141,7 +136,9 @@ fun GeohashPeopleList( hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"), isTeleported = personIsTeleported, viewModel = viewModel, - showHashSuffix = (baseNameCounts[splitSuffix(person.displayName).first] ?: 0) > 1, + showHashSuffix = splitSuffix(person.displayName) + .first + .lowercase(Locale.ROOT) in duplicateBaseNames, onTap = { if (!isMe) { viewModel.startGeohashDM(person.id) @@ -175,6 +172,39 @@ internal data class GeohashPeopleSections( val teleportedIn: List ) +/** + * Names that require a short identity suffix, calculated across both people sections. + * + * Matching is case-insensitive to mirror geohash chat's nickname collision handling. + */ +internal fun duplicateGeohashBaseNames(people: List): Set = + people + .groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) } + .eachCount() + .filterValues { it > 1 } + .keys + +/** + * The same `#abcd` disambiguator used by geohash chat. + * + * Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr + * public key when a collision exists. Preserve an already-announced suffix for compatibility. + */ +internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String { + if (!showHashSuffix) return "" + val announcedSuffix = splitSuffix(person.displayName).second + return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" } +} + +internal fun disambiguatedGeohashDisplayName( + person: GeoPerson, + duplicateBaseNames: Set, +): String { + val baseName = splitSuffix(person.displayName).first + val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames + return baseName + geohashIdentitySuffix(person, showSuffix) +} + /** * Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities * are omitted, while announced names such as `anon1234` remain ordinary participants. Self is @@ -249,11 +279,11 @@ private fun GeohashPersonItem( if (isTeleported) R.drawable.ic_spec_teleport else R.drawable.ic_spec_on_location_person - val (baseNameRaw, suffixRaw) = splitSuffix(person.displayName) + val (baseNameRaw, _) = splitSuffix(person.displayName) val baseName = truncateNickname(baseNameRaw) - val suffix = if (showHashSuffix) suffixRaw else "" - val assignedColor = colorForPeerSeed( - viewModel.peerColorSeedForNostrPubkey(person.id), + val suffix = geohashIdentitySuffix(person, showHashSuffix) + val assignedColor = colorForPeer( + viewModel.peerIdentityForNostrPubkey(person.id), palette ) val baseColor = if (isMe) palette.accentOrange else assignedColor @@ -314,7 +344,7 @@ private fun GeohashPersonItem( text = suffix, fontFamily = BitchatFontFamily, fontSize = 14.sp, - fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium, + fontWeight = FontWeight.Normal, color = baseColor.copy(alpha = SUFFIX_ALPHA) ) } diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index d94e5fff..5376e23e 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -332,8 +332,8 @@ class GeohashViewModel( fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash) - fun peerColorSeedForNostrPubkey(pubkeyHex: String): PeerColorSeed = - nostrPeerColorSeed(pubkeyHex) + fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity = + PeerIdentity.nostr(pubkeyHex) private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { geoTimer?.cancel(); geoTimer = null diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index a732b8e8..c946eaf6 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.text.withStyle import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.features.voice.normalizeAmplitudeSample import com.bitchat.android.features.voice.AudioWaveformExtractor import com.bitchat.android.ui.media.RealtimeScrollingWaveform @@ -709,6 +710,7 @@ fun CommandSuggestionItem( @Composable fun MentionSuggestionsBox( suggestions: List, + mentionPeerIdentities: Map, onSuggestionClick: (String) -> Unit, modifier: Modifier = Modifier ) { @@ -732,8 +734,11 @@ fun MentionSuggestionsBox( items = suggestions, key = { suggestion -> suggestion.lowercase() } ) { suggestion -> + val identity = resolveMentionPeerIdentity(suggestion, mentionPeerIdentities) + ?: PeerIdentity.nickname(suggestion) MentionSuggestionItem( suggestion = suggestion, + identity = identity, onClick = { onSuggestionClick(suggestion) }, modifier = Modifier.animateItem( fadeInSpec = tween( @@ -754,15 +759,17 @@ fun MentionSuggestionsBox( @Composable fun MentionSuggestionItem( suggestion: String, + identity: PeerIdentity, onClick: () -> Unit, modifier: Modifier = Modifier ) { val palette = LocalBitchatPalette.current + val userColor = colorForPeer(identity, palette) val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val pressedBackground by animateColorAsState( targetValue = if (isPressed) { - palette.accentOrange.copy(alpha = 0.10f) + userColor.copy(alpha = 0.10f) } else { Color.Transparent }, @@ -798,7 +805,7 @@ fun MentionSuggestionItem( fontFamily = BitchatFontFamily, fontWeight = FontWeight.SemiBold ), - color = palette.accentOrange, + color = userColor, fontSize = (BASE_FONT_SIZE - 2).sp, maxLines = 1, overflow = TextOverflow.Ellipsis, 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 4bb36ecc..5f2394bc 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -47,7 +47,7 @@ import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette -import com.bitchat.android.ui.theme.colorForPeerSeed +import com.bitchat.android.ui.theme.colorForPeer import com.bitchat.android.nostr.GeohashAliasRegistry import com.bitchat.android.nostr.GeohashConversationRegistry import com.bitchat.android.services.ContactDirectory @@ -589,8 +589,8 @@ private fun PeerItem( // Get consistent peer color (iOS-compatible) val palette = LocalBitchatPalette.current - val assignedColor = colorForPeerSeed( - viewModel.peerColorSeedForMeshPeer(peerID), + val assignedColor = colorForPeer( + viewModel.peerIdentityForMeshPeer(peerID), palette ) val baseColor = if (isMe) palette.accentOrange else assignedColor 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 c8e05af0..16c7f63d 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -182,6 +182,7 @@ fun MessagesList( currentUserNickname: String, meshService: MeshService, modifier: Modifier = Modifier, + mentionPeerIdentities: Map? = null, /** * Extra inset on top of the list's own gutters. * @@ -204,6 +205,10 @@ fun MessagesList( onCancelTransfer: ((BitchatMessage) -> Unit)? = null, onImageClick: ((String, List, Int) -> Unit)? = null ) { + val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) { + mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages) + } + // A fresh scroll position per conversation. Sharing one state meant a switch inherited the // previous channel's offset and then had to correct itself, which is what the jump was. // @@ -331,6 +336,7 @@ fun MessagesList( messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = resolvedMentionPeerIdentities, showSender = !isGrouped, topSpacing = MessageGrouping.topSpacingFor( isGrouped = isGrouped, @@ -363,6 +369,7 @@ fun MessageItem( currentUserNickname: String, meshService: MeshService, messages: List = emptyList(), + mentionPeerIdentities: Map = emptyMap(), showSender: Boolean = true, topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, @@ -394,6 +401,7 @@ fun MessageItem( messages = messages, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, @@ -432,6 +440,7 @@ fun MessageItem( messages: List, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map, colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, showSender: Boolean, @@ -623,6 +632,7 @@ fun MessageItem( message = message, currentUserNickname = currentUserNickname, meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, colorScheme = colorScheme, timeFormatter = timeFormatter, showSender = showSender, @@ -638,6 +648,7 @@ internal fun TextMessageLayout( message: BitchatMessage, currentUserNickname: String, meshService: MeshService, + mentionPeerIdentities: Map = emptyMap(), colorScheme: ColorScheme, timeFormatter: SimpleDateFormat, onNicknameClick: ((String) -> Unit)?, @@ -667,6 +678,7 @@ internal fun TextMessageLayout( palette, colorScheme.onSurface, colorScheme.secondary, + mentionPeerIdentities, timeFormatter ) { formatTextMessageBody( @@ -675,6 +687,7 @@ internal fun TextMessageLayout( palette = palette, contentColor = colorScheme.onSurface, linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, timeFormatter = timeFormatter, ) } diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt deleted file mode 100644 index b6eac7cd..00000000 --- a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.bitchat.android.ui - -import com.bitchat.android.model.BitchatMessage -import java.util.Locale - -/** - * Stable, presentation-neutral identity used to derive a peer hue. - * - * ViewModels may expose this value, but only the UI theme resolves it to a rendered color. - */ -@JvmInline -value class PeerColorSeed(val value: String) - -fun meshPeerColorSeed(peerID: String): PeerColorSeed = - PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}") - -fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed = - PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}") - -fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed { - val value = when { - message.senderPeerID?.startsWith("nostr:") == true || - message.senderPeerID?.startsWith("nostr_") == true -> { - "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}" - } - message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> { - "noise:${message.senderPeerID.lowercase(Locale.ROOT)}" - } - else -> message.sender.lowercase(Locale.ROOT) - } - - return PeerColorSeed(value) -} diff --git a/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt new file mode 100644 index 00000000..47dd5629 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt @@ -0,0 +1,71 @@ +package com.bitchat.android.ui + +import com.bitchat.android.model.BitchatMessage +import java.util.Locale + +/** + * Canonical, presentation-neutral identity used to derive a peer hue. + * + * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced + * here so the same mesh or Nostr user resolves to the same color on every surface. + */ +@JvmInline +value class PeerIdentity private constructor(internal val stableKey: String) { + companion object { + fun mesh(peerID: String): PeerIdentity = + PeerIdentity("noise:${normalize(peerID)}") + + /** + * Preserve the established geohash-chat color mapping while accepting a full public key. + * + * Older chat rendering hashed `nostr:nostr:`. Keeping that visual + * key here avoids recoloring existing conversations; the important change is that every + * surface now derives it from the same canonical Nostr identity. + */ + fun nostr(pubkeyHex: String): PeerIdentity { + val normalized = normalizeNostrIdentifier(pubkeyHex) + return PeerIdentity("nostr:nostr:${normalized.take(8)}") + } + + /** + * Last-resort identity for legacy messages and mentions that carry no stable peer ID. + */ + fun nickname(nickname: String): PeerIdentity = + PeerIdentity(normalize(nickname)) + + private fun normalize(value: String): String = + value.trim().lowercase(Locale.ROOT) + + private fun normalizeNostrIdentifier(value: String): String { + var normalized = normalize(value) + while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) { + normalized = normalized.removePrefix("nostr:").removePrefix("nostr_") + } + return normalized + } + } +} + +/** + * Resolve the canonical identity attached to a rendered message. + * + * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a + * compatibility fallback for messages saved by older app versions. + */ +fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity { + message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let { + return PeerIdentity.nostr(it) + } + + val senderPeerID = message.senderPeerID + return when { + senderPeerID?.startsWith("nostr:", ignoreCase = true) == true || + senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> { + PeerIdentity.nostr(senderPeerID) + } + senderPeerID?.length == 16 || senderPeerID?.length == 64 -> { + PeerIdentity.mesh(senderPeerID) + } + else -> PeerIdentity.nickname(message.sender) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt index 2a0e0a38..b0f1ed7d 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt @@ -1,18 +1,18 @@ package com.bitchat.android.ui.theme import androidx.compose.ui.graphics.Color -import com.bitchat.android.ui.PeerColorSeed +import com.bitchat.android.ui.PeerIdentity import kotlin.math.abs /** - * Resolve a peer's stable seed using the active Bitchat theme's explicit chroma tokens. + * The single identity-to-color boundary used by chat, people sheets, and mentions. * * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is * avoided because it is reserved for the current user. */ -fun colorForPeerSeed(seed: PeerColorSeed, palette: BitchatPalette): Color { +fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color { var hash = 5381UL - for (byte in seed.value.toByteArray()) { + for (byte in identity.stableKey.toByteArray()) { hash = ((hash shl 5) + hash) + byte.toUByte().toULong() } 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 59f0eb73..84142117 100644 --- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt @@ -13,11 +13,12 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme import com.bitchat.android.ui.theme.LightBitchatPalette import com.bitchat.android.ui.theme.MessageBodyTextStyle import com.bitchat.android.ui.theme.MessageSenderTextStyle -import com.bitchat.android.ui.theme.colorForPeerSeed +import com.bitchat.android.ui.theme.colorForPeer import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -221,19 +222,56 @@ class ChatUIUtilsTest { @Test fun `mention of another user is tinted by that user's own peer color`() { + val pubkey = "0123456789abcdef".repeat(4) + val identity = PeerIdentity.nostr(pubkey) + val mentionPeerIdentities = buildMentionPeerIdentityMap( + messages = listOf( + BitchatMessage( + sender = "carol#04af", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + ) val body = formatTextMessageBody( message = message("cc @carol#04af"), currentUserNickname = "bob", palette = palette, contentColor = colorScheme.onSurface, linkColor = colorScheme.secondary, + mentionPeerIdentities = mentionPeerIdentities, timeFormatter = timeFormatter, includeTimestamp = false, ) - val expected = colorForPeerSeed(PeerColorSeed("carol#04af"), palette) + val expected = colorForPeer(identity, palette) val chip = mentionChipSpans(body).single() assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background) + assertTrue(body.spanStyles.any { it.item.color == expected }) + } + + @Test + fun `ambiguous base nickname is not assigned to the wrong peer`() { + val firstIdentity = PeerIdentity.nostr("11111111".repeat(8)) + val secondIdentity = PeerIdentity.nostr("22222222".repeat(8)) + val identities = buildMentionPeerIdentityMap( + messages = emptyList(), + knownPeers = listOf( + "alice#1111" to firstIdentity, + "alice#2222" to secondIdentity, + ) + ) + + assertEquals(firstIdentity, identities["alice#1111"]) + assertEquals(secondIdentity, identities["alice#2222"]) + assertFalse(identities.containsKey("alice")) + assertEquals( + firstIdentity, + resolveMentionPeerIdentity("@alice#1111", identities) + ) + assertEquals(null, resolveMentionPeerIdentity("@alice", identities)) } @Test @@ -349,24 +387,33 @@ class ChatUIUtilsTest { // MARK: - Peer colors @Test - fun `peer seed factories normalize identities without resolving UI colors`() { + fun `peer identity factories normalize stable IDs without resolving UI colors`() { assertEquals( - PeerColorSeed("noise:abcdef"), - meshPeerColorSeed("ABCDEF") + PeerIdentity.mesh("abcdef"), + PeerIdentity.mesh("ABCDEF") ) assertEquals( - PeerColorSeed("nostr:abcdef"), - nostrPeerColorSeed("ABCDEF") + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("ABCDEF") ) + assertEquals( + PeerIdentity.nostr("abcdef"), + PeerIdentity.nostr("nostr:nostr_ABCDEF") + ) + assertEquals( + "nostr:nostr:abcdef01", + PeerIdentity.nostr("ABCDEF0123456789").stableKey + ) + assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey) } @Test fun `peer color hue is stable across light and dark, only chroma differs`() { // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for // the redesigned neutral message body. - val seed = PeerColorSeed("noise:abc") - val dark = colorForPeerSeed(seed, DarkBitchatPalette) - val light = colorForPeerSeed(seed, LightBitchatPalette) + val identity = PeerIdentity.mesh("abc") + val dark = colorForPeer(identity, DarkBitchatPalette) + val light = colorForPeer(identity, LightBitchatPalette) val darkHsv = FloatArray(3) val lightHsv = FloatArray(3) @@ -384,8 +431,8 @@ class ChatUIUtilsTest { fun `peer color avoids the orange hue reserved for self`() { // Sweep a range of seeds; none may land within the reserved orange band. repeat(500) { i -> - val color = colorForPeerSeed( - PeerColorSeed("noise:seed$i"), + val color = colorForPeer( + PeerIdentity.mesh("seed$i"), DarkBitchatPalette ) val hsv = FloatArray(3) @@ -407,6 +454,59 @@ class ChatUIUtilsTest { ) } + @Test + fun `geohash chat and people sheet resolve the same full Nostr identity`() { + val pubkey = "ABCDEF0123456789".repeat(4) + val peopleIdentity = PeerIdentity.nostr(pubkey) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr:${pubkey.take(8)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + assertEquals( + colorForPeer(peopleIdentity, palette), + colorForPeer(chatIdentity, palette) + ) + } + + @Test + fun `mesh chat and people sheet resolve the same peer identity`() { + val peerID = "ABCDEF0123456789" + val peopleIdentity = PeerIdentity.mesh(peerID) + val chatIdentity = peerIdentityForMessage( + BitchatMessage( + sender = "alice#1234", + content = "hello", + timestamp = Date(0), + senderPeerID = peerID, + ) + ) + + assertEquals(peopleIdentity, chatIdentity) + } + + @Test + fun `full Nostr identity wins over a truncated routing alias`() { + val pubkey = "0123456789ABCDEF".repeat(4) + val identity = peerIdentityForMessage( + BitchatMessage( + sender = "alice", + content = "hello", + timestamp = Date(0), + senderPeerID = "nostr_${pubkey.take(16)}", + senderNostrPubkey = pubkey, + ) + ) + + assertEquals(PeerIdentity.nostr(pubkey), identity) + } + private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) { val max = maxOf(r, g, b) val min = minOf(r, g, b) diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt index 3c655f44..869ae924 100644 --- a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt @@ -80,4 +80,51 @@ class GeohashPresenceGroupingTest { assertTrue(sections.onLocation.isEmpty()) assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id }) } + + @Test + fun `duplicate nicknames are detected across case and sections`() { + val duplicates = duplicateGeohashBaseNames( + listOf( + person("first", "Alice"), + person("second", "alice"), + person("third", "bob") + ) + ) + + assertEquals(setOf("alice"), duplicates) + } + + @Test + fun `duplicate nickname gets the same last-four ID suffix as chat`() { + assertEquals( + "#cdef", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true) + ) + assertEquals( + "", + geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false) + ) + } + + @Test + fun `existing chat-style suffix is preserved`() { + assertEquals( + "#04af", + geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true) + ) + } + + @Test + fun `disambiguated display name matches the mention token used by chat`() { + val alice = person("0123456789abcdef", "alice") + + assertEquals( + "alice#cdef", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice")) + ) + assertEquals( + "alice", + disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet()) + ) + } }