This commit is contained in:
callebtc
2026-01-17 01:34:20 +07:00
parent a81bf4489d
commit ad7034577d
7 changed files with 100 additions and 63 deletions
@@ -130,6 +130,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
// Messages area - takes up available space, will compress when keyboard appears
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
@@ -137,6 +138,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
peerNicknames = peerNicknames,
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
@@ -44,7 +44,8 @@ fun formatMessageAsAnnotatedString(
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
peerNicknames: Map<String, String> = emptyMap()
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -53,26 +54,26 @@ fun formatMessageAsAnnotatedString(
val isSelf = message.senderPeerID == meshService.myPeerID ||
message.sender == currentUserNickname ||
message.sender.startsWith("$currentUserNickname#")
if (message.sender != "system") {
// Resolve disambiguated sender name if it's a mesh message
val senderDisplayName = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
val disambiguated = meshService.getDisambiguatedNickname(message.senderPeerID)
// If it returned the raw peerID because the peer is offline, fall back to message.sender (nickname at time of send)
if (disambiguated == message.senderPeerID && message.sender != message.senderPeerID) {
message.sender
message.sender
} else {
disambiguated
disambiguated
}
} else {
message.sender
}
// 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)
colorForPeer(message.senderPeerID, message.sender, isDark)
}
// Split sender into base name and hashtag suffix
@@ -130,10 +131,9 @@ fun formatMessageAsAnnotatedString(
builder.pop()
// Message content with iOS-style hashtag and mention highlighting
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark, meshService, peerNicknames)
// iOS-style timestamp at the END (smaller, grey)
// Timestamp (and optional PoW badge)
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
@@ -152,7 +152,7 @@ fun formatMessageAsAnnotatedString(
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
fontStyle = FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
@@ -177,7 +177,8 @@ fun formatMessageHeaderAnnotatedString(
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault()),
peerNicknames: Map<String, String> = emptyMap()
): AnnotatedString {
val builder = AnnotatedString.Builder()
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -189,18 +190,12 @@ fun formatMessageHeaderAnnotatedString(
if (message.sender != "system") {
// Resolve disambiguated sender name if it's a mesh message
val senderDisplayName = if (message.senderPeerID != null && !message.senderPeerID.startsWith("nostr_")) {
val disambiguated = meshService.getDisambiguatedNickname(message.senderPeerID)
// If it returned the raw peerID because the peer is offline, fall back to message.sender (nickname at time of send)
if (disambiguated == message.senderPeerID && message.sender != message.senderPeerID) {
message.sender
} else {
disambiguated
}
meshService.getDisambiguatedNickname(message.senderPeerID)
} else {
message.sender
}
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
val baseColor = if (isSelf) Color(0xFFFF9500) else colorForPeer(message.senderPeerID, message.sender, isDark)
val (baseName, suffix) = splitSuffix(senderDisplayName)
// "<@"
@@ -266,7 +261,7 @@ fun formatMessageHeaderAnnotatedString(
builder.pushStyle(SpanStyle(
color = Color.Gray,
fontSize = (BASE_FONT_SIZE - 2).sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
fontStyle = FontStyle.Italic
))
builder.append("* ${message.content} *")
builder.pop()
@@ -286,23 +281,26 @@ fun formatMessageHeaderAnnotatedString(
* Avoids orange (~30°) reserved for self messages
*/
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
return colorForPeer(message.senderPeerID, message.sender, isDark)
}
/**
* Generate consistent peer color based on peer ID or nickname
*/
fun colorForPeer(peerID: String?, nickname: String, 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()}"
peerID?.startsWith("nostr:") == true || peerID?.startsWith("nostr_") == true -> {
"nostr:${peerID.lowercase()}"
}
message.senderPeerID?.length == 16 -> {
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
"noise:${message.senderPeerID.lowercase()}"
peerID?.length == 16 -> {
"noise:${peerID.lowercase()}"
}
message.senderPeerID?.length == 64 -> {
// This is already a stable Noise key
"noise:${message.senderPeerID.lowercase()}"
peerID?.length == 64 -> {
"noise:${peerID.lowercase()}"
}
else -> {
// Fallback to sender name
message.sender.lowercase()
nickname.lowercase()
}
}
@@ -354,6 +352,34 @@ fun splitSuffix(name: String): Pair<String, String> {
return Pair(name, "")
}
/**
* Truncate nickname if it's too long for UI display
*/
fun truncateNickname(nickname: String): String {
return if (nickname.length > 20) {
nickname.take(18) + ".."
} else {
nickname
}
}
/**
* Resolve a display name (with potential #suffix) back to a Peer ID
*/
fun resolvePeerIDFromDisplayName(displayName: String, nicknames: Map<String, String>): String? {
val (base, suffix) = splitSuffix(displayName)
return nicknames.entries.find { (id, nick) ->
if (suffix.isNotEmpty()) {
// Match nickname and Peer ID ending
nick.equals(base, ignoreCase = true) && id.endsWith(suffix.removePrefix("#"), ignoreCase = true)
} else {
// Match nickname only
nick.equals(displayName, ignoreCase = true)
}
}?.key
}
/**
* iOS-style content formatting with proper hashtag and mention handling
*/
@@ -364,7 +390,9 @@ private fun appendIOSFormattedContent(
currentUserNickname: String,
baseColor: Color,
isSelf: Boolean,
isDark: Boolean
isDark: Boolean,
meshService: BluetoothMeshService,
peerNicknames: Map<String, String> = emptyMap()
) {
// iOS-style patterns: allow optional '#abcd' suffix in mentions
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
@@ -396,7 +424,6 @@ private fun appendIOSFormattedContent(
}
// Add standalone geohash matches (e.g., "#9q") that are not part of another word
// We use MessageSpecialParser to find exact ranges; then merge with existing ranges avoiding overlaps
val geoMatches = MessageSpecialParser.findStandaloneGeohashes(content)
for (gm in geoMatches) {
val range = gm.start until gm.endExclusive
@@ -414,7 +441,7 @@ private fun appendIOSFormattedContent(
}
}
// Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering
// Remove generic hashtag matches that overlap with detected geohash ranges
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
return a.first < b.last && a.last > b.first
}
@@ -424,7 +451,6 @@ private fun appendIOSFormattedContent(
val iterator = allMatches.listIterator()
while (iterator.hasNext()) {
val (range, type) = iterator.next()
// Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs
val overlapsGeo = geoRanges.any { rangesOverlap(range, it) }
val overlapsUrl = urlRanges.any { rangesOverlap(range, it) }
if ((type == "hashtag" && overlapsGeo) || (type == "geohash" && overlapsUrl)) iterator.remove()
@@ -436,6 +462,9 @@ private fun appendIOSFormattedContent(
var lastEnd = 0
val isMentioned = mentions?.contains(currentUserNickname) == true
// Use provided map if available, else query service once
val nicknameMap = if (peerNicknames.isNotEmpty()) peerNicknames else meshService.getPeerNicknames()
for ((range, type) in allMatches) {
// Add text before match
if (lastEnd < range.first) {
@@ -447,7 +476,6 @@ private fun appendIOSFormattedContent(
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()
@@ -462,13 +490,19 @@ private fun appendIOSFormattedContent(
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
val isMentionToMe = mBase.equals(currentUserNickname, ignoreCase = true)
// Resolve mention color based on mentioned user's actual color
val mentionColor = if (isMentionToMe) {
Color(0xFFFF9500) // Orange for mentions to self
} else {
val resolvedID = resolvePeerIDFromDisplayName(mentionWithoutAt, nicknameMap)
colorForPeer(resolvedID, mentionWithoutAt, isDark)
}
// "@" symbol
builder.pushStyle(SpanStyle(
@@ -479,7 +513,7 @@ private fun appendIOSFormattedContent(
builder.append("@")
builder.pop()
// Base name (truncate for rendering)
// Base name
builder.pushStyle(SpanStyle(
color = mentionColor,
fontSize = BASE_FONT_SIZE.sp,
@@ -500,7 +534,6 @@ private fun appendIOSFormattedContent(
}
}
"hashtag" -> {
// Render general hashtags like normal content
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
@@ -517,7 +550,6 @@ private fun appendIOSFormattedContent(
}
else -> {
if (type == "geohash") {
// Style geohash in blue, underlined, and add click annotation
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
fontSize = BASE_FONT_SIZE.sp,
@@ -528,15 +560,9 @@ private fun appendIOSFormattedContent(
builder.append(matchText)
val end = builder.length
val geohash = matchText.removePrefix("#").lowercase()
builder.addStringAnnotation(
tag = "geohash_click",
annotation = geohash,
start = start,
end = end
)
builder.addStringAnnotation(tag = "geohash_click", annotation = geohash, start = start, end = end)
builder.pop()
} else if (type == "url") {
// Style URL in blue, underlined, and add click annotation with the raw text
builder.pushStyle(SpanStyle(
color = Color(0xFF007AFF),
fontSize = BASE_FONT_SIZE.sp,
@@ -546,15 +572,9 @@ private fun appendIOSFormattedContent(
val start = builder.length
builder.append(matchText)
val end = builder.length
builder.addStringAnnotation(
tag = "url_click",
annotation = matchText,
start = start,
end = end
)
builder.addStringAnnotation(tag = "url_click", annotation = matchText, start = start, end = end)
builder.pop()
} else {
// Fallback: treat as normal text
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = BASE_FONT_SIZE.sp,
@@ -251,7 +251,8 @@ private fun formatMessageAsAnnotatedStringWithoutTimestamp(
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
timeFormatter = timeFormatter,
peerNicknames = emptyMap() // Can't easily get map here without refactoring AnimatedMessageDisplay
)
// Find and remove the timestamp and PoW badge at the end
@@ -839,7 +839,8 @@ fun PrivateChatSheet(
onNicknameClick = { /* handle mention */ },
onMessageLongPress = { /* handle long press */ },
onCancelTransfer = { msg -> viewModel.cancelMediaSend(msg.id) },
onImageClick = { _, _, _ -> /* handle image click */ }
onImageClick = { _, _, _ -> /* handle image click */ },
peerNicknames = peerNicknames
)
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
@@ -65,7 +65,8 @@ fun MessagesList(
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
peerNicknames: Map<String, String> = emptyMap()
) {
val listState = rememberLazyListState()
@@ -126,7 +127,8 @@ fun MessagesList(
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick
onImageClick = onImageClick,
peerNicknames = peerNicknames
)
}
}
@@ -142,7 +144,8 @@ fun MessageItem(
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List<String>, Int) -> Unit)? = null
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
peerNicknames: Map<String, String> = emptyMap()
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
@@ -171,6 +174,7 @@ fun MessageItem(
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick,
peerNicknames = peerNicknames,
modifier = Modifier
.weight(1f)
.padding(end = endPad)
@@ -208,6 +212,7 @@ fun MessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
peerNicknames: Map<String, String> = emptyMap(),
modifier: Modifier = Modifier
) {
// Image special rendering
@@ -223,6 +228,7 @@ fun MessageItem(
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
onImageClick = onImageClick,
peerNicknames = peerNicknames,
modifier = modifier
)
return
@@ -239,6 +245,7 @@ fun MessageItem(
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
peerNicknames = peerNicknames,
modifier = modifier
)
return
@@ -263,7 +270,8 @@ fun MessageItem(
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
timeFormatter = timeFormatter,
peerNicknames = peerNicknames
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -371,7 +379,8 @@ fun MessageItem(
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
timeFormatter = timeFormatter,
peerNicknames = peerNicknames
)
// Check if this message was sent by self to avoid click interactions on own nickname
@@ -36,6 +36,7 @@ fun AudioMessageItem(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
peerNicknames: Map<String, String> = emptyMap(),
modifier: Modifier = Modifier
) {
val path = message.content.trim()
@@ -55,7 +56,8 @@ fun AudioMessageItem(
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
timeFormatter = timeFormatter,
peerNicknames = peerNicknames
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -46,6 +46,7 @@ fun ImageMessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List<String>, Int) -> Unit)?,
peerNicknames: Map<String, String> = emptyMap(),
modifier: Modifier = Modifier
) {
val path = message.content.trim()
@@ -55,7 +56,8 @@ fun ImageMessageItem(
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
timeFormatter = timeFormatter,
peerNicknames = peerNicknames
)
val haptic = LocalHapticFeedback.current
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }