mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:25:19 +00:00
* english done * de * more extraction * wip strings en * translations work * remove unneeded translations * remove notification message * add languages * new languages
523 lines
22 KiB
Kotlin
523 lines
22 KiB
Kotlin
package com.bitchat.android.ui
|
|
|
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
import androidx.compose.ui.draw.clip
|
|
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.AnnotatedString
|
|
import androidx.compose.ui.text.SpanStyle
|
|
import androidx.compose.ui.text.buildAnnotatedString
|
|
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.*
|
|
import com.bitchat.android.ui.media.VoiceNotePlayer
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material.icons.Icons
|
|
import androidx.compose.material.icons.filled.Close
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.shape.CircleShape
|
|
import com.bitchat.android.ui.media.FileMessageItem
|
|
import com.bitchat.android.model.BitchatMessageType
|
|
import com.bitchat.android.R
|
|
import androidx.compose.ui.res.stringResource
|
|
|
|
|
|
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
|
|
|
|
/**
|
|
* 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,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
|
onImageClick: ((String, List<String>, Int) -> 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(
|
|
items = messages.asReversed(),
|
|
key = { it.id }
|
|
) { message ->
|
|
MessageItem(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@OptIn(ExperimentalFoundationApi::class)
|
|
@Composable
|
|
fun MessageItem(
|
|
message: BitchatMessage,
|
|
currentUserNickname: String,
|
|
meshService: BluetoothMeshService,
|
|
messages: List<BitchatMessage> = emptyList(),
|
|
onNicknameClick: ((String) -> Unit)? = null,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
|
|
onImageClick: ((String, List<String>, Int) -> Unit)? = null
|
|
) {
|
|
val colorScheme = MaterialTheme.colorScheme
|
|
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
|
|
|
Column(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
|
) {
|
|
Box(modifier = Modifier.fillMaxWidth()) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.Start,
|
|
verticalAlignment = Alignment.Top
|
|
) {
|
|
// Provide a small end padding for own private messages so overlay doesn't cover text
|
|
val endPad = if (message.isPrivate && message.sender == currentUserNickname) 16.dp else 0.dp
|
|
// Create a custom layout that combines selectable text with clickable nickname areas
|
|
MessageTextWithClickableNicknames(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick,
|
|
modifier = Modifier
|
|
.weight(1f)
|
|
.padding(end = endPad)
|
|
)
|
|
}
|
|
|
|
// Delivery status for private messages (overlay, non-displacing)
|
|
if (message.isPrivate && message.sender == currentUserNickname) {
|
|
message.deliveryStatus?.let { status ->
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.padding(top = 2.dp)
|
|
) {
|
|
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,
|
|
messages: List<BitchatMessage>,
|
|
currentUserNickname: String,
|
|
meshService: BluetoothMeshService,
|
|
colorScheme: ColorScheme,
|
|
timeFormatter: SimpleDateFormat,
|
|
onNicknameClick: ((String) -> Unit)?,
|
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
|
onCancelTransfer: ((BitchatMessage) -> Unit)?,
|
|
onImageClick: ((String, List<String>, Int) -> Unit)?,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
// Image special rendering
|
|
if (message.type == BitchatMessageType.Image) {
|
|
com.bitchat.android.ui.media.ImageMessageItem(
|
|
message = message,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
onImageClick = onImageClick,
|
|
modifier = modifier
|
|
)
|
|
return
|
|
}
|
|
|
|
// Voice note special rendering
|
|
if (message.type == BitchatMessageType.Audio) {
|
|
com.bitchat.android.ui.media.AudioMessageItem(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onCancelTransfer = onCancelTransfer,
|
|
modifier = modifier
|
|
)
|
|
return
|
|
}
|
|
|
|
// File special rendering
|
|
if (message.type == BitchatMessageType.File) {
|
|
val path = message.content.trim()
|
|
// Derive sending progress if applicable
|
|
val (overrideProgress, _) = when (val st = message.deliveryStatus) {
|
|
is com.bitchat.android.model.DeliveryStatus.PartiallyDelivered -> {
|
|
if (st.total > 0 && st.reached < st.total) {
|
|
(st.reached.toFloat() / st.total.toFloat()) to Color(0xFF1E88E5) // blue while sending
|
|
} else null to null
|
|
}
|
|
else -> null to null
|
|
}
|
|
Column(modifier = modifier.fillMaxWidth()) {
|
|
// Header: nickname + timestamp line above the file, identical styling to text messages
|
|
val headerText = formatMessageHeaderAnnotatedString(
|
|
message = message,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter
|
|
)
|
|
val haptic = LocalHapticFeedback.current
|
|
var headerLayout by remember { mutableStateOf<TextLayoutResult?>(null) }
|
|
Text(
|
|
text = headerText,
|
|
fontFamily = FontFamily.Monospace,
|
|
color = colorScheme.onSurface,
|
|
modifier = Modifier.pointerInput(message.id) {
|
|
detectTapGestures(onTap = { pos ->
|
|
val layout = headerLayout ?: return@detectTapGestures
|
|
val offset = layout.getOffsetForPosition(pos)
|
|
val ann = headerText.getStringAnnotations("nickname_click", offset, offset)
|
|
if (ann.isNotEmpty() && onNicknameClick != null) {
|
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
|
onNicknameClick.invoke(ann.first().item)
|
|
}
|
|
}, onLongPress = { onMessageLongPress?.invoke(message) })
|
|
},
|
|
onTextLayout = { headerLayout = it }
|
|
)
|
|
|
|
// Try to load the file packet from the path
|
|
val packet = try {
|
|
val file = java.io.File(path)
|
|
if (file.exists()) {
|
|
// Create a temporary BitchatFilePacket for display
|
|
// In a real implementation, this would be stored with the packet metadata
|
|
com.bitchat.android.model.BitchatFilePacket(
|
|
fileName = file.name,
|
|
fileSize = file.length(),
|
|
mimeType = com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name),
|
|
content = file.readBytes()
|
|
)
|
|
} else null
|
|
} catch (e: Exception) {
|
|
null
|
|
}
|
|
|
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
|
Box {
|
|
if (packet != null) {
|
|
if (overrideProgress != null) {
|
|
// Show sending animation while in-flight
|
|
com.bitchat.android.ui.media.FileSendingAnimation(
|
|
fileName = packet.fileName,
|
|
progress = overrideProgress,
|
|
modifier = Modifier.fillMaxWidth()
|
|
)
|
|
} else {
|
|
// Static file display with open/save dialog
|
|
FileMessageItem(
|
|
packet = packet,
|
|
onFileClick = {
|
|
// handled inside FileMessageItem via dialog
|
|
}
|
|
)
|
|
}
|
|
|
|
// Cancel button overlay during sending
|
|
val showCancel = message.sender == currentUserNickname && (message.deliveryStatus is DeliveryStatus.PartiallyDelivered)
|
|
if (showCancel) {
|
|
Box(
|
|
modifier = Modifier
|
|
.align(Alignment.TopEnd)
|
|
.padding(4.dp)
|
|
.size(22.dp)
|
|
.background(Color.Gray.copy(alpha = 0.6f), CircleShape)
|
|
.clickable { onCancelTransfer?.invoke(message) },
|
|
contentAlignment = Alignment.Center
|
|
) {
|
|
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
|
|
}
|
|
}
|
|
} else {
|
|
Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 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,
|
|
messages = messages,
|
|
currentUserNickname = currentUserNickname,
|
|
meshService = meshService,
|
|
colorScheme = colorScheme,
|
|
timeFormatter = timeFormatter,
|
|
onNicknameClick = onNicknameClick,
|
|
onMessageLongPress = onMessageLongPress,
|
|
onImageClick = onImageClick,
|
|
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 = stringResource(R.string.status_sending),
|
|
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 = stringResource(R.string.status_pending),
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
is DeliveryStatus.Delivered -> {
|
|
// Single check for Delivered (matches iOS expectations)
|
|
Text(
|
|
text = stringResource(R.string.status_sent),
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.8f)
|
|
)
|
|
}
|
|
is DeliveryStatus.Read -> {
|
|
Text(
|
|
text = stringResource(R.string.status_delivered),
|
|
fontSize = 10.sp,
|
|
color = Color(0xFF007AFF), // Blue
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
}
|
|
is DeliveryStatus.Failed -> {
|
|
Text(
|
|
text = stringResource(R.string.status_failed),
|
|
fontSize = 10.sp,
|
|
color = Color.Red.copy(alpha = 0.8f)
|
|
)
|
|
}
|
|
is DeliveryStatus.PartiallyDelivered -> {
|
|
// Show a single subdued check without numeric label
|
|
Text(
|
|
text = stringResource(R.string.status_sent),
|
|
fontSize = 10.sp,
|
|
color = colorScheme.primary.copy(alpha = 0.6f)
|
|
)
|
|
}
|
|
}
|
|
}
|