mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:25:19 +00:00
* add pow * animation * matrix style * animation better * improve animation * improve animation * works * fix jump * difficulty indicator * 10 is default * animation runs forever * pow in message timestamp * pow works * default on * adjust animation * no printing
337 lines
14 KiB
Kotlin
337 lines
14 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.foundation.gestures.detectTapGestures
|
|
import androidx.compose.foundation.layout.*
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
|
|
|
|
import androidx.compose.material3.*
|
|
import androidx.compose.runtime.*
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.text.TextLayoutResult
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextOverflow
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.sp
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
import android.content.Intent
|
|
import android.net.Uri
|
|
import com.bitchat.android.model.BitchatMessage
|
|
import com.bitchat.android.model.DeliveryStatus
|
|
import com.bitchat.android.mesh.BluetoothMeshService
|
|
import java.text.SimpleDateFormat
|
|
import java.util.*
|
|
|
|
/**
|
|
* Message display components for ChatScreen
|
|
* Extracted from ChatScreen.kt for better organization
|
|
*/
|
|
|
|
@Composable
|
|
fun MessagesList(
|
|
messages: List<BitchatMessage>,
|
|
currentUserNickname: String,
|
|
meshService: BluetoothMeshService,
|
|
modifier: Modifier = Modifier,
|
|
forceScrollToBottom: Boolean = false,
|
|
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
|
|
onNicknameClick: ((String) -> Unit)? = null,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
|
|
) {
|
|
val listState = rememberLazyListState()
|
|
|
|
// Track if this is the first time messages are being loaded
|
|
var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
|
|
|
|
// Smart scroll: auto-scroll to bottom for initial load, then only when user is at or near the bottom
|
|
LaunchedEffect(messages.size) {
|
|
if (messages.isNotEmpty()) {
|
|
val layoutInfo = listState.layoutInfo
|
|
val firstVisibleIndex = layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
|
|
|
// With reverseLayout=true and reversed data, index 0 is the latest message at the bottom
|
|
val isFirstLoad = !hasScrolledToInitialPosition
|
|
val isNearLatest = firstVisibleIndex <= 2
|
|
|
|
if (isFirstLoad || isNearLatest) {
|
|
listState.animateScrollToItem(0)
|
|
if (isFirstLoad) {
|
|
hasScrolledToInitialPosition = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Track whether user has scrolled away from the latest messages
|
|
val isAtLatest by remember {
|
|
derivedStateOf {
|
|
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
|
|
firstVisibleIndex <= 2
|
|
}
|
|
}
|
|
LaunchedEffect(isAtLatest) {
|
|
onScrolledUpChanged?.invoke(!isAtLatest)
|
|
}
|
|
|
|
// Force scroll to bottom when requested (e.g., when user sends a message)
|
|
LaunchedEffect(forceScrollToBottom) {
|
|
if (messages.isNotEmpty()) {
|
|
// With reverseLayout=true and reversed data, latest is at index 0
|
|
listState.animateScrollToItem(0)
|
|
}
|
|
}
|
|
|
|
LazyColumn(
|
|
state = listState,
|
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
|
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
modifier = modifier,
|
|
reverseLayout = true
|
|
) {
|
|
items(messages.asReversed()) { message ->
|
|
MessageItem(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun MessageItem(
|
|
message: BitchatMessage,
|
|
currentUserNickname: String,
|
|
meshService: BluetoothMeshService,
|
|
onNicknameClick: ((String) -> Unit)? = null,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
|
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
|
) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
// Create a custom layout that combines selectable text with clickable nickname areas
|
|
MessageTextWithClickableNicknames(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
|
|
// Delivery status for private messages
|
|
if (message.isPrivate && message.sender == currentUserNickname) {
|
|
message.deliveryStatus?.let { status ->
|
|
DeliveryStatusIcon(status = status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Link previews removed; links are now highlighted inline and clickable within the message text
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
private fun MessageTextWithClickableNicknames(
|
|
message: BitchatMessage,
|
|
currentUserNickname: String,
|
|
meshService: BluetoothMeshService,
|
|
colorScheme: ColorScheme,
|
|
timeFormatter: SimpleDateFormat,
|
|
onNicknameClick: ((String) -> Unit)?,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
// Check if this message should be animated during PoW mining
|
|
val shouldAnimate = shouldAnimateMessage(message.id)
|
|
|
|
// If animation is needed, use the matrix animation component for content only
|
|
if (shouldAnimate) {
|
|
// Display message with matrix animation for content
|
|
MessageWithMatrixAnimation(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
modifier = modifier
|
|
)
|
|
} else {
|
|
// Normal message display
|
|
val annotatedText = formatMessageAsAnnotatedString(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter
|
|
)
|
|
|
|
// Check if this message was sent by self to avoid click interactions on own nickname
|
|
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
|
message.sender == currentUserNickname ||
|
|
message.sender.startsWith("$currentUserNickname#")
|
|
|
|
val haptic = LocalHapticFeedback.current
|
|
val context = LocalContext.current
|
|
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
|
Text(
|
|
text = annotatedText,
|
|
modifier = modifier.pointerInput(message) {
|
|
detectTapGestures(
|
|
onTap = { position ->
|
|
val layout = textLayoutResult ?: return@detectTapGestures
|
|
val offset = layout.getOffsetForPosition(position)
|
|
// Nickname click only when not self
|
|
if (!isSelf && onNicknameClick != null) {
|
|
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
|
tag = "nickname_click",
|
|
start = offset,
|
|
end = offset
|
|
)
|
|
if (nicknameAnnotations.isNotEmpty()) {
|
|
val nickname = nicknameAnnotations.first().item
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
onNicknameClick.invoke(nickname)
|
|
return@detectTapGestures
|
|
}
|
|
}
|
|
// Geohash teleport (all messages)
|
|
val geohashAnnotations = annotatedText.getStringAnnotations(
|
|
tag = "geohash_click",
|
|
start = offset,
|
|
end = offset
|
|
)
|
|
if (geohashAnnotations.isNotEmpty()) {
|
|
val geohash = geohashAnnotations.first().item
|
|
try {
|
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
|
context
|
|
)
|
|
val level = when (geohash.length) {
|
|
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
|
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
|
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
|
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
|
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
|
}
|
|
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
|
locationManager.setTeleported(true)
|
|
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
|
} catch (_: Exception) { }
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
return@detectTapGestures
|
|
}
|
|
// URL open (all messages)
|
|
val urlAnnotations = annotatedText.getStringAnnotations(
|
|
tag = "url_click",
|
|
start = offset,
|
|
end = offset
|
|
)
|
|
if (urlAnnotations.isNotEmpty()) {
|
|
val raw = urlAnnotations.first().item
|
|
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
|
try {
|
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
context.startActivity(intent)
|
|
} catch (_: Exception) { }
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
return@detectTapGestures
|
|
}
|
|
},
|
|
onLongPress = {
|
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
onMessageLongPress?.invoke(message)
|
|
}
|
|
)
|
|
},
|
|
fontFamily = FontFamily.Monospace,
|
|
softWrap = true,
|
|
overflow = TextOverflow.Visible,
|
|
style = androidx.compose.ui.text.TextStyle(
|
|
color = colorScheme.onSurface
|
|
),
|
|
onTextLayout = { result -> textLayoutResult = result }
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
|
|
when (status) {
|
|
is DeliveryStatus.Sending -> {
|
|
Text(
|
|
text = "○",
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
is DeliveryStatus.Sent -> {
|
|
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
|
|
Text(
|
|
text = "○",
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
is DeliveryStatus.Delivered -> {
|
|
// Single check for Delivered (matches iOS expectations)
|
|
Text(
|
|
text = "✓",
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.8f)
|
|
)
|
|
}
|
|
is DeliveryStatus.Read -> {
|
|
Text(
|
|
text = "✓✓",
|
|
fontSize = 10.sp,
|
|
color = Color(0xFF007AFF), // Blue
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
}
|
|
is DeliveryStatus.Failed -> {
|
|
Text(
|
|
text = "⚠",
|
|
fontSize = 10.sp,
|
|
color = Color.Red.copy(alpha = 0.8f)
|
|
)
|
|
}
|
|
is DeliveryStatus.PartiallyDelivered -> {
|
|
Text(
|
|
text = "✓${status.reached}/${status.total}",
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
}
|
|
}
|