mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-22 07:06:05 +00:00
fix mention colors
This commit is contained in:
parent
25e9779f73
commit
5c8f03ce18
@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
71
app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt
Normal file
71
app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user