fix mention colors

This commit is contained in:
callebtc 2026-07-27 16:33:05 +02:00
parent 25e9779f73
commit 5c8f03ce18
17 changed files with 430 additions and 88 deletions

View File

@ -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(
}
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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<CommandSuggestion>,
showMentionSuggestions: Boolean,
mentionSuggestions: List<String>,
mentionPeerIdentities: Map<String, PeerIdentity> = 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()
)

View File

@ -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<String, PeerIdentity> = 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<String, String> {
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<BitchatMessage>,
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
): Map<String, PeerIdentity> {
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
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<String, PeerIdentity>,
): 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<String, PeerIdentity>,
) {
// 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
)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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<GeoPerson>
)
/**
* 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<GeoPerson>): Set<String> =
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>,
): 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)
)
}

View File

@ -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

View File

@ -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<String>,
mentionPeerIdentities: Map<String, PeerIdentity>,
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,

View File

@ -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

View File

@ -182,6 +182,7 @@ fun MessagesList(
currentUserNickname: String,
meshService: MeshService,
modifier: Modifier = Modifier,
mentionPeerIdentities: Map<String, PeerIdentity>? = null,
/**
* Extra inset on top of the list's own gutters.
*
@ -204,6 +205,10 @@ fun MessagesList(
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, 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<BitchatMessage> = emptyList(),
mentionPeerIdentities: Map<String, PeerIdentity> = 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<BitchatMessage>,
currentUserNickname: String,
meshService: MeshService,
mentionPeerIdentities: Map<String, PeerIdentity>,
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<String, PeerIdentity> = 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,
)
}

View File

@ -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)
}

View File

@ -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:<first 8 hex characters>`. 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)
}
}

View File

@ -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()
}

View File

@ -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)

View File

@ -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())
)
}
}