Files
bitchat-android/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
T

368 lines
13 KiB
Kotlin

package com.bitchat.android.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
import androidx.compose.material3.ColorScheme
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.text.SimpleDateFormat
import java.util.*
/**
* Utility functions for ChatScreen UI components
* Extracted from ChatScreen.kt for better organization
*/
/**
* Get RSSI-based color for signal strength visualization
*/
fun getRSSIColor(rssi: Int): Color {
return when {
rssi >= -50 -> Color(0xFF00FF00) // Bright green
rssi >= -60 -> Color(0xFF80FF00) // Green-yellow
rssi >= -70 -> Color(0xFFFFFF00) // Yellow
rssi >= -80 -> Color(0xFFFF8000) // Orange
else -> Color(0xFFFF4444) // Red
}
}
/**
* Format message as annotated string with iOS-style formatting
* Timestamp at END, peer colors, hashtag suffix handling
*/
fun formatMessageAsAnnotatedString(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
// Determine if this message was sent by self
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") {
// Get base color for this peer (iOS-style color assignment)
val baseColor = if (isSelf) {
Color(0xFFFF9500) // Orange for self (iOS orange)
} else {
getPeerColor(message, isDark)
}
// Split sender into base name and hashtag suffix
val (baseName, suffix) = splitSuffix(message.sender)
// Sender prefix "<@"
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("<@")
builder.pop()
// Base name (clickable)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
val nicknameStart = builder.length
builder.append(baseName)
val nicknameEnd = builder.length
// Add click annotation for nickname (store canonical sender name with hash if available)
if (!isSelf) {
builder.addStringAnnotation(
tag = "nickname_click",
annotation = (message.originalSender ?: message.sender),
start = nicknameStart,
end = nicknameEnd
)
}
builder.pop()
// Hashtag suffix in lighter color (iOS style)
if (suffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor.copy(alpha = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append(suffix)
builder.pop()
}
// Sender suffix "> "
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("> ")
builder.pop()
// Message content with iOS-style hashtag and mention highlighting
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
// iOS-style timestamp at the END (smaller, grey)
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.pop()
} else {
// System message - iOS style
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
// Timestamp for system messages too
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.5f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.pop()
}
return builder.toAnnotatedString()
}
/**
* iOS-style peer color assignment using djb2 hash algorithm
* Avoids orange (~30°) reserved for self messages
*/
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
// Create seed from peer identifier (prioritizing stable keys)
val seed = when {
message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> {
// For Nostr peers, use the full key if available, otherwise the peer ID
"nostr:${message.senderPeerID.lowercase()}"
}
message.senderPeerID?.length == 16 -> {
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
"noise:${message.senderPeerID.lowercase()}"
}
message.senderPeerID?.length == 64 -> {
// This is already a stable Noise key
"noise:${message.senderPeerID.lowercase()}"
}
else -> {
// Fallback to sender name
message.sender.lowercase()
}
}
return colorForPeerSeed(seed, isDark)
}
/**
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
*/
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
// djb2 hash algorithm (matches iOS implementation)
var hash = 5381UL
for (byte in seed.toByteArray()) {
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
}
var hue = (hash % 360UL).toDouble() / 360.0
// Avoid orange (~30°) reserved for self (matches iOS logic)
val orange = 30.0 / 360.0
if (kotlin.math.abs(hue - orange) < 0.05) {
hue = (hue + 0.12) % 1.0
}
val saturation = if (isDark) 0.50 else 0.70
val brightness = if (isDark) 0.85 else 0.35
return Color.hsv(
hue = (hue * 360).toFloat(),
saturation = saturation.toFloat(),
value = brightness.toFloat()
)
}
/**
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
*/
fun splitSuffix(name: String): Pair<String, String> {
if (name.length < 5) return Pair(name, "")
val suffix = name.takeLast(5)
if (suffix.startsWith("#") && suffix.drop(1).all {
it.isDigit() || it.lowercaseChar() in 'a'..'f'
}) {
val base = name.dropLast(5)
return Pair(base, suffix)
}
return Pair(name, "")
}
/**
* iOS-style content formatting with proper hashtag and mention handling
*/
private fun appendIOSFormattedContent(
builder: AnnotatedString.Builder,
content: String,
mentions: List<String>?,
currentUserNickname: String,
baseColor: Color,
isSelf: Boolean,
isDark: Boolean
) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
val hashtagMatches = hashtagPattern.findAll(content).toList()
val mentionMatches = mentionPattern.findAll(content).toList()
// Combine and sort matches, but exclude hashtags that overlap with mentions
val mentionRanges = mentionMatches.map { it.range }
fun overlapsMention(range: IntRange): Boolean {
return mentionRanges.any { mentionRange ->
range.first < mentionRange.last && range.last > mentionRange.first
}
}
val allMatches = mutableListOf<Pair<IntRange, String>>()
// Add hashtag matches that don't overlap with mentions
for (match in hashtagMatches) {
if (!overlapsMention(match.range)) {
allMatches.add(match.range to "hashtag")
}
}
// Add all mention matches
for (match in mentionMatches) {
allMatches.add(match.range to "mention")
}
allMatches.sortBy { it.first.first }
var lastEnd = 0
val isMentioned = mentions?.contains(currentUserNickname) == true
for ((range, type) in allMatches) {
// Add text before match
if (lastEnd < range.first) {
val beforeText = content.substring(lastEnd, range.first)
if (beforeText.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
// Make entire message bold if user is mentioned
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(beforeText)
builder.pop()
} else {
builder.append(beforeText)
}
builder.pop()
}
}
// Add styled match
val matchText = content.substring(range.first, range.last + 1)
when (type) {
"mention" -> {
// iOS-style mention with hashtag suffix support
val mentionWithoutAt = matchText.removePrefix("@")
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
// Check if this mention targets current user
val isMentionToMe = mBase == currentUserNickname
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
// "@" symbol
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
))
builder.append("@")
builder.pop()
// Base name
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
))
builder.append(mBase)
builder.pop()
// Hashtag suffix in lighter color
if (mSuffix.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = mentionColor.copy(alpha = 0.6f),
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
))
builder.append(mSuffix)
builder.pop()
}
}
"hashtag" -> {
// iOS-style: render hashtags like normal content (no special styling)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(matchText)
builder.pop()
} else {
builder.append(matchText)
}
builder.pop()
}
}
lastEnd = range.last + 1
}
// Add remaining text
if (lastEnd < content.length) {
val remainingText = content.substring(lastEnd)
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
))
if (isMentioned) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
builder.append(remainingText)
builder.pop()
} else {
builder.append(remainingText)
}
builder.pop()
}
}