Refactor code structure for improved readability and maintainability

This commit is contained in:
k1ngsterr1
2026-01-31 04:37:23 +05:00
parent 56a9fc4c20
commit 5fdd30b0ae
2 changed files with 851 additions and 762 deletions

View File

@@ -461,6 +461,10 @@ fun ImageAttachment(
fillMaxSize: Boolean = false, fillMaxSize: Boolean = false,
onImageClick: (attachmentId: String) -> Unit = {} onImageClick: (attachmentId: String) -> Unit = {}
) { ) {
Log.d(
TAG,
"🖼️ ImageAttachment: id=${attachment.id}, isOutgoing=$isOutgoing, messageStatus=$messageStatus, showTimeOverlay=$showTimeOverlay"
)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -845,7 +849,7 @@ fun ImageAttachment(
} }
MessageStatus.DELIVERED -> { MessageStatus.DELIVERED -> {
Icon( Icon(
compose.icons.TablerIcons.Checks, compose.icons.TablerIcons.Check,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
@@ -855,7 +859,7 @@ fun ImageAttachment(
Icon( Icon(
compose.icons.TablerIcons.Checks, compose.icons.TablerIcons.Checks,
contentDescription = null, contentDescription = null,
tint = Color(0xFF4FC3F7), tint = Color.White,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }
@@ -1578,7 +1582,7 @@ fun AvatarAttachment(
} }
MessageStatus.DELIVERED -> { MessageStatus.DELIVERED -> {
Icon( Icon(
compose.icons.TablerIcons.Checks, compose.icons.TablerIcons.Check,
contentDescription = "Delivered", contentDescription = "Delivered",
tint = Color.White.copy(alpha = 0.6f), tint = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
@@ -1588,7 +1592,7 @@ fun AvatarAttachment(
Icon( Icon(
compose.icons.TablerIcons.Checks, compose.icons.TablerIcons.Checks,
contentDescription = "Read", contentDescription = "Read",
tint = Color(0xFF4FC3F7), tint = Color.White,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
} }

View File

@@ -1,6 +1,9 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@@ -11,21 +14,16 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -37,38 +35,34 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import android.graphics.BitmapFactory
import android.util.Base64
import android.util.Log
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
import kotlinx.coroutines.Dispatchers import compose.icons.TablerIcons
import kotlinx.coroutines.delay import compose.icons.tablericons.*
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* Reusable UI components for Chat Detail Screen * Reusable UI components for Chat Detail Screen Extracted from ChatDetailScreen.kt for better
* Extracted from ChatDetailScreen.kt for better organization * organization
*/ */
/** Date header with fade-in animation */ /** Date header with fade-in animation */
@Composable @Composable
fun DateHeader(dateText: String, secondaryTextColor: Color) { fun DateHeader(dateText: String, secondaryTextColor: Color) {
var isVisible by remember { mutableStateOf(false) } var isVisible by remember { mutableStateOf(false) }
val alpha by animateFloatAsState( val alpha by
animateFloatAsState(
targetValue = if (isVisible) 1f else 0f, targetValue = if (isVisible) 1f else 0f,
animationSpec = tween(durationMillis = 250, easing = TelegramEasing), animationSpec = tween(durationMillis = 250, easing = TelegramEasing),
label = "dateAlpha" label = "dateAlpha"
@@ -77,10 +71,10 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) {
LaunchedEffect(dateText) { isVisible = true } LaunchedEffect(dateText) { isVisible = true }
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth().padding(vertical = 12.dp).graphicsLayer {
.padding(vertical = 12.dp) this.alpha = alpha
.graphicsLayer { this.alpha = alpha }, },
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
@@ -88,8 +82,8 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) {
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor, color = secondaryTextColor,
modifier = Modifier modifier =
.background( Modifier.background(
color = secondaryTextColor.copy(alpha = 0.1f), color = secondaryTextColor.copy(alpha = 0.1f),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
@@ -111,11 +105,14 @@ fun TypingIndicator(isDarkTheme: Boolean) {
Text(text = "typing", fontSize = 13.sp, color = typingColor) Text(text = "typing", fontSize = 13.sp, color = typingColor)
repeat(3) { index -> repeat(3) { index ->
val offsetY by infiniteTransition.animateFloat( val offsetY by
infiniteTransition.animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = -4f, targetValue = -4f,
animationSpec = infiniteRepeatable( animationSpec =
animation = tween( infiniteRepeatable(
animation =
tween(
durationMillis = 600, durationMillis = 600,
delayMillis = index * 100, delayMillis = index * 100,
easing = FastOutSlowInEasing easing = FastOutSlowInEasing
@@ -162,7 +159,8 @@ fun MessageBubble(
val swipeThreshold = 80f val swipeThreshold = 80f
val maxSwipe = 120f val maxSwipe = 120f
val animatedOffset by animateFloatAsState( val animatedOffset by
animateFloatAsState(
targetValue = swipeOffset, targetValue = swipeOffset,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "swipeOffset" label = "swipeOffset"
@@ -171,19 +169,22 @@ fun MessageBubble(
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// Selection animations // Selection animations
val selectionScale by animateFloatAsState( val selectionScale by
animateFloatAsState(
targetValue = if (isSelected) 0.95f else 1f, targetValue = if (isSelected) 0.95f else 1f,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "selectionScale" label = "selectionScale"
) )
val selectionAlpha by animateFloatAsState( val selectionAlpha by
animateFloatAsState(
targetValue = if (isSelected) 0.85f else 1f, targetValue = if (isSelected) 0.85f else 1f,
animationSpec = tween(150), animationSpec = tween(150),
label = "selectionAlpha" label = "selectionAlpha"
) )
// Colors // Colors
val bubbleColor = remember(message.isOutgoing, isDarkTheme) { val bubbleColor =
remember(message.isOutgoing, isDarkTheme) {
if (message.isOutgoing) { if (message.isOutgoing) {
PrimaryBlue PrimaryBlue
} else { } else {
@@ -191,31 +192,35 @@ fun MessageBubble(
} }
} }
val textColor = remember(message.isOutgoing, isDarkTheme) { val textColor =
remember(message.isOutgoing, isDarkTheme) {
if (message.isOutgoing) Color.White if (message.isOutgoing) Color.White
else if (isDarkTheme) Color.White else Color(0xFF000000) else if (isDarkTheme) Color.White else Color(0xFF000000)
} }
val timeColor = remember(message.isOutgoing, isDarkTheme) { val timeColor =
remember(message.isOutgoing, isDarkTheme) {
if (message.isOutgoing) Color.White.copy(alpha = 0.7f) if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
} }
val bubbleShape = remember(message.isOutgoing, showTail) { val bubbleShape =
remember(message.isOutgoing, showTail) {
RoundedCornerShape( RoundedCornerShape(
topStart = 18.dp, topStart = 18.dp,
topEnd = 18.dp, topEnd = 18.dp,
bottomStart = if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp), bottomStart =
bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp if (message.isOutgoing) 18.dp else (if (showTail) 4.dp else 18.dp),
bottomEnd =
if (message.isOutgoing) (if (showTail) 4.dp else 18.dp) else 18.dp
) )
} }
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth().pointerInput(Unit) {
.pointerInput(Unit) {
detectHorizontalDragGestures( detectHorizontalDragGestures(
onDragEnd = { onDragEnd = {
if (swipeOffset <= -swipeThreshold) { if (swipeOffset <= -swipeThreshold) {
@@ -233,18 +238,16 @@ fun MessageBubble(
) { ) {
// Reply icon // Reply icon
Box( Box(
modifier = Modifier modifier =
.align(Alignment.CenterEnd) Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer {
.padding(end = 16.dp)
.graphicsLayer {
alpha = swipeProgress alpha = swipeProgress
scaleX = swipeProgress scaleX = swipeProgress
scaleY = swipeProgress scaleY = swipeProgress
} }
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(36.dp) Modifier.size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (swipeProgress >= 1f) PrimaryBlue if (swipeProgress >= 1f) PrimaryBlue
@@ -256,7 +259,8 @@ fun MessageBubble(
Icon( Icon(
TablerIcons.CornerUpLeft, TablerIcons.CornerUpLeft,
contentDescription = "Reply", contentDescription = "Reply",
tint = if (swipeProgress >= 1f) Color.White tint =
if (swipeProgress >= 1f) Color.White
else if (isDarkTheme) Color.White.copy(alpha = 0.7f) else if (isDarkTheme) Color.White.copy(alpha = 0.7f)
else Color(0xFF666666), else Color(0xFF666666),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
@@ -264,23 +268,30 @@ fun MessageBubble(
} }
} }
val selectionBackgroundColor by animateColorAsState( val selectionBackgroundColor by
targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, animateColorAsState(
targetValue =
if (isSelected) PrimaryBlue.copy(alpha = 0.15f)
else Color.Transparent,
animationSpec = tween(200), animationSpec = tween(200),
label = "selectionBg" label = "selectionBg"
) )
val highlightBackgroundColor by animateColorAsState( val highlightBackgroundColor by
targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent, animateColorAsState(
targetValue =
if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f)
else Color.Transparent,
animationSpec = tween(300), animationSpec = tween(300),
label = "highlightBg" label = "highlightBg"
) )
val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor val combinedBackgroundColor =
if (isSelected) selectionBackgroundColor else highlightBackgroundColor
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.background(combinedBackgroundColor) .background(combinedBackgroundColor)
.padding(vertical = 2.dp) .padding(vertical = 2.dp)
.offset { IntOffset(animatedOffset.toInt(), 0) }, .offset { IntOffset(animatedOffset.toInt(), 0) },
@@ -290,15 +301,17 @@ fun MessageBubble(
// Selection checkmark // Selection checkmark
AnimatedVisibility( AnimatedVisibility(
visible = isSelected, visible = isSelected,
enter = fadeIn(tween(150)) + scaleIn( enter =
fadeIn(tween(150)) +
scaleIn(
initialScale = 0.3f, initialScale = 0.3f,
animationSpec = spring(dampingRatio = 0.6f) animationSpec = spring(dampingRatio = 0.6f)
), ),
exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f)
) { ) {
Box( Box(
modifier = Modifier modifier =
.padding(start = 12.dp, end = 4.dp) Modifier.padding(start = 12.dp, end = 4.dp)
.size(24.dp) .size(24.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFF4CD964)), .background(Color(0xFF4CD964)),
@@ -317,29 +330,34 @@ fun MessageBubble(
visible = !isSelected, visible = !isSelected,
enter = fadeIn(tween(100)), enter = fadeIn(tween(100)),
exit = fadeOut(tween(100)) exit = fadeOut(tween(100))
) { ) { Spacer(modifier = Modifier.width(12.dp)) }
Spacer(modifier = Modifier.width(12.dp))
}
if (message.isOutgoing) { if (message.isOutgoing) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
// Проверяем - есть ли только фотки без текста // Проверяем - есть ли только фотки без текста
val hasOnlyMedia = message.attachments.isNotEmpty() && val hasOnlyMedia =
message.attachments.isNotEmpty() &&
message.text.isEmpty() && message.text.isEmpty() &&
message.replyData == null && message.replyData == null &&
message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE } message.attachments.all {
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE
}
// Фото + caption (как в Telegram) // Фото + caption (как в Telegram)
val hasImageWithCaption = message.attachments.isNotEmpty() && val hasImageWithCaption =
message.attachments.isNotEmpty() &&
message.text.isNotEmpty() && message.text.isNotEmpty() &&
message.replyData == null && message.replyData == null &&
message.attachments.all { it.type == com.rosetta.messenger.network.AttachmentType.IMAGE } message.attachments.all {
it.type == com.rosetta.messenger.network.AttachmentType.IMAGE
}
// Для сообщений только с фото - минимальный padding и тонкий border // Для сообщений только с фото - минимальный padding и тонкий border
// Для фото + caption - padding только внизу для текста // Для фото + caption - padding только внизу для текста
val bubblePadding = when { val bubblePadding =
when {
hasOnlyMedia -> PaddingValues(0.dp) hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp) hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -347,8 +365,8 @@ fun MessageBubble(
val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp val bubbleBorderWidth = if (hasOnlyMedia) 1.dp else 0.dp
Box( Box(
modifier = Modifier modifier =
.padding(end = 12.dp) Modifier.padding(end = 12.dp)
.widthIn(min = 60.dp, max = 280.dp) .widthIn(min = 60.dp, max = 280.dp)
.wrapContentWidth(unbounded = false) .wrapContentWidth(unbounded = false)
.graphicsLayer { .graphicsLayer {
@@ -358,7 +376,8 @@ fun MessageBubble(
} }
.combinedClickable( .combinedClickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() }, interactionSource =
remember { MutableInteractionSource() },
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) )
@@ -367,10 +386,18 @@ fun MessageBubble(
if (hasOnlyMedia) { if (hasOnlyMedia) {
Modifier.border( Modifier.border(
width = bubbleBorderWidth, width = bubbleBorderWidth,
color = if (message.isOutgoing) { color =
if (message.isOutgoing) {
Color.White.copy(alpha = 0.15f) Color.White.copy(alpha = 0.15f)
} else { } else {
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) if (isDarkTheme)
Color.White.copy(
alpha = 0.1f
)
else
Color.Black.copy(
alpha = 0.08f
)
}, },
shape = bubbleShape shape = bubbleShape
) )
@@ -393,6 +420,8 @@ fun MessageBubble(
// 📎 Attachments (IMAGE, FILE, AVATAR) // 📎 Attachments (IMAGE, FILE, AVATAR)
if (message.attachments.isNotEmpty()) { if (message.attachments.isNotEmpty()) {
val attachmentDisplayStatus =
if (isSavedMessages) MessageStatus.READ else message.status
MessageAttachments( MessageAttachments(
attachments = message.attachments, attachments = message.attachments,
chachaKey = message.chachaKey, chachaKey = message.chachaKey,
@@ -401,10 +430,11 @@ fun MessageBubble(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
timestamp = message.timestamp, timestamp = message.timestamp,
messageStatus = message.status, messageStatus = attachmentDisplayStatus,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
hasCaption = hasImageWithCaption, // Если есть caption - время на пузырьке, не на фото hasCaption = hasImageWithCaption, // Если есть caption - время на
// пузырьке, не на фото
onImageClick = onImageClick onImageClick = onImageClick
) )
} }
@@ -412,10 +442,13 @@ fun MessageBubble(
// 🖼️ Caption под фото (как в Telegram) // 🖼️ Caption под фото (как в Telegram)
if (hasImageWithCaption) { if (hasImageWithCaption) {
Column( Column(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.background(bubbleColor) .background(bubbleColor)
.padding(horizontal = 10.dp, vertical = 6.dp) // Уменьшил padding .padding(
horizontal = 10.dp,
vertical = 6.dp
) // Уменьшил padding
) { ) {
Row( Row(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -439,10 +472,13 @@ fun MessageBubble(
text = timeFormat.format(message.timestamp), text = timeFormat.format(message.timestamp),
color = timeColor, color = timeColor,
fontSize = 11.sp, fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic fontStyle =
androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status = displayStatus,
timeColor = timeColor, timeColor = timeColor,
@@ -486,7 +522,9 @@ fun MessageBubble(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status = displayStatus,
timeColor = timeColor, timeColor = timeColor,
@@ -523,7 +561,9 @@ fun MessageBubble(
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
) )
if (message.isOutgoing) { if (message.isOutgoing) {
val displayStatus = if (isSavedMessages) MessageStatus.READ else message.status val displayStatus =
if (isSavedMessages) MessageStatus.READ
else message.status
AnimatedMessageStatus( AnimatedMessageStatus(
status = displayStatus, status = displayStatus,
timeColor = timeColor, timeColor = timeColor,
@@ -550,18 +590,19 @@ fun AnimatedMessageStatus(
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {} onDelete: () -> Unit = {}
) { ) {
val isTimedOut = status == MessageStatus.SENDING && val isTimedOut =
timestamp > 0 && status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp)
!isMessageDeliveredByTime(timestamp)
val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status
val targetColor = when (effectiveStatus) { val targetColor =
when (effectiveStatus) {
MessageStatus.READ -> Color(0xFF4FC3F7) MessageStatus.READ -> Color(0xFF4FC3F7)
MessageStatus.ERROR -> Color(0xFFE53935) MessageStatus.ERROR -> Color(0xFFE53935)
else -> timeColor else -> timeColor
} }
val animatedColor by animateColorAsState( val animatedColor by
animateColorAsState(
targetValue = targetColor, targetValue = targetColor,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "statusColor" label = "statusColor"
@@ -577,9 +618,11 @@ fun AnimatedMessageStatus(
} }
} }
val scale by animateFloatAsState( val scale by
animateFloatAsState(
targetValue = if (shouldAnimate) 1.2f else 1f, targetValue = if (shouldAnimate) 1.2f else 1f,
animationSpec = spring( animationSpec =
spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
), ),
@@ -598,7 +641,8 @@ fun AnimatedMessageStatus(
val iconSize = with(LocalDensity.current) { 14.sp.toDp() } val iconSize = with(LocalDensity.current) { 14.sp.toDp() }
Icon( Icon(
imageVector = when (currentStatus) { imageVector =
when (currentStatus) {
MessageStatus.SENDING -> TablerIcons.Clock MessageStatus.SENDING -> TablerIcons.Clock
MessageStatus.SENT -> TablerIcons.Check MessageStatus.SENT -> TablerIcons.Check
MessageStatus.DELIVERED -> TablerIcons.Check MessageStatus.DELIVERED -> TablerIcons.Check
@@ -607,8 +651,8 @@ fun AnimatedMessageStatus(
}, },
contentDescription = null, contentDescription = null,
tint = animatedColor, tint = animatedColor,
modifier = Modifier modifier =
.size(iconSize) Modifier.size(iconSize)
.scale(scale) .scale(scale)
.then( .then(
if (currentStatus == MessageStatus.ERROR) { if (currentStatus == MessageStatus.ERROR) {
@@ -618,10 +662,7 @@ fun AnimatedMessageStatus(
) )
} }
DropdownMenu( DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) {
expanded = showErrorMenu,
onDismissRequest = { showErrorMenu = false }
) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Retry") }, text = { Text("Retry") },
onClick = { onClick = {
@@ -664,7 +705,8 @@ fun ReplyBubble(
onClick: () -> Unit = {} onClick: () -> Unit = {}
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val backgroundColor = if (isOutgoing) { val backgroundColor =
if (isOutgoing) {
Color.Black.copy(alpha = 0.15f) Color.Black.copy(alpha = 0.15f)
} else { } else {
Color.Black.copy(alpha = 0.08f) Color.Black.copy(alpha = 0.08f)
@@ -672,7 +714,8 @@ fun ReplyBubble(
val borderColor = if (isOutgoing) Color.White else PrimaryBlue val borderColor = if (isOutgoing) Color.White else PrimaryBlue
val nameColor = if (isOutgoing) Color.White else PrimaryBlue val nameColor = if (isOutgoing) Color.White else PrimaryBlue
val replyTextColor = if (isOutgoing) { val replyTextColor =
if (isOutgoing) {
Color.White.copy(alpha = 0.85f) Color.White.copy(alpha = 0.85f)
} else { } else {
if (isDarkTheme) Color.White else Color.Black if (isDarkTheme) Color.White else Color.Black
@@ -693,9 +736,12 @@ fun ReplyBubble(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
// Получаем blurhash из preview (может быть в формате "tag::blurhash") // Получаем blurhash из preview (может быть в формате "tag::blurhash")
val blurhash = if (imageAttachment.preview.contains("::")) { val blurhash =
if (imageAttachment.preview.contains("::")) {
imageAttachment.preview.substringAfter("::") imageAttachment.preview.substringAfter("::")
} else if (!imageAttachment.preview.startsWith("http") && imageAttachment.preview.length < 50) { } else if (!imageAttachment.preview.startsWith("http") &&
imageAttachment.preview.length < 50
) {
imageAttachment.preview imageAttachment.preview
} else { } else {
"" ""
@@ -717,14 +763,20 @@ fun ReplyBubble(
try { try {
// Пробуем сначала из blob // Пробуем сначала из blob
if (imageAttachment.blob.isNotEmpty()) { if (imageAttachment.blob.isNotEmpty()) {
val decoded = try { val decoded =
val cleanBase64 = if (imageAttachment.blob.contains(",")) { try {
val cleanBase64 =
if (imageAttachment.blob.contains(",")) {
imageAttachment.blob.substringAfter(",") imageAttachment.blob.substringAfter(",")
} else { } else {
imageAttachment.blob imageAttachment.blob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) BitmapFactory.decodeByteArray(
decodedBytes,
0,
decodedBytes.size
)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -735,7 +787,8 @@ fun ReplyBubble(
} }
// Если blob нет - загружаем из локального файла // Если blob нет - загружаем из локального файла
val localBlob = AttachmentFileManager.readAttachment( val localBlob =
AttachmentFileManager.readAttachment(
context, context,
imageAttachment.id, imageAttachment.id,
replyData.senderPublicKey, replyData.senderPublicKey,
@@ -743,14 +796,20 @@ fun ReplyBubble(
) )
if (localBlob != null) { if (localBlob != null) {
val decoded = try { val decoded =
val cleanBase64 = if (localBlob.contains(",")) { try {
val cleanBase64 =
if (localBlob.contains(",")) {
localBlob.substringAfter(",") localBlob.substringAfter(",")
} else { } else {
localBlob localBlob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) BitmapFactory.decodeByteArray(
decodedBytes,
0,
decodedBytes.size
)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -764,8 +823,8 @@ fun ReplyBubble(
} }
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
@@ -776,17 +835,22 @@ fun ReplyBubble(
// Контент reply // Контент reply
Row( Row(
modifier = Modifier modifier =
.weight(1f) Modifier.weight(1f)
.padding(start = 8.dp, end = if (hasImage) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp), .padding(
start = 8.dp,
end = if (hasImage) 4.dp else 10.dp,
top = 4.dp,
bottom = 4.dp
),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Текстовая часть // Текстовая часть
Column( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.weight(1f)
) {
Text( Text(
text = if (replyData.isForwarded) "Forwarded message" else replyData.senderName, text =
if (replyData.isForwarded) "Forwarded message"
else replyData.senderName,
color = nameColor, color = nameColor,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -795,7 +859,8 @@ fun ReplyBubble(
) )
// Текст или "Photo" // Текст или "Photo"
val displayText = when { val displayText =
when {
replyData.text.isNotEmpty() -> replyData.text replyData.text.isNotEmpty() -> replyData.text
hasImage -> "Photo" hasImage -> "Photo"
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
@@ -815,8 +880,8 @@ fun ReplyBubble(
if (hasImage) { if (hasImage) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = Modifier modifier =
.size(36.dp) Modifier.size(36.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(Color.Gray.copy(alpha = 0.3f)) .background(Color.Gray.copy(alpha = 0.3f))
) { ) {
@@ -861,10 +926,12 @@ fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0)
val infiniteTransition = rememberInfiniteTransition(label = "shimmer") val infiniteTransition = rememberInfiniteTransition(label = "shimmer")
val shimmerAlpha by infiniteTransition.animateFloat( val shimmerAlpha by
infiniteTransition.animateFloat(
initialValue = 0.4f, initialValue = 0.4f,
targetValue = 0.8f, targetValue = 0.8f,
animationSpec = infiniteRepeatable( animationSpec =
infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing), animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse repeatMode = RepeatMode.Reverse
), ),
@@ -873,8 +940,8 @@ fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
Column( Column(
modifier = Modifier modifier =
.align(Alignment.BottomCenter) Modifier.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.padding(bottom = 80.dp), .padding(bottom = 80.dp),
@@ -902,8 +969,8 @@ private fun SkeletonBubble(
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start
) { ) {
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth(widthFraction) Modifier.fillMaxWidth(widthFraction)
.defaultMinSize(minHeight = 44.dp) .defaultMinSize(minHeight = 44.dp)
.clip( .clip(
RoundedCornerShape( RoundedCornerShape(
@@ -931,13 +998,15 @@ fun KebabMenu(
onUnblockClick: () -> Unit, onUnblockClick: () -> Unit,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
) { ) {
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) val dividerColor =
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.width(220.dp), modifier = Modifier.width(220.dp),
properties = PopupProperties( properties =
PopupProperties(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true dismissOnClickOutside = true
@@ -953,8 +1022,8 @@ fun KebabMenu(
) )
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp) .height(0.5.dp)
.background(dividerColor) .background(dividerColor)
@@ -1027,7 +1096,8 @@ fun ReplyImagePreview(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
// Получаем blurhash из preview (может быть в формате "tag::blurhash") // Получаем blurhash из preview (может быть в формате "tag::blurhash")
val blurhash = if (attachment.preview.contains("::")) { val blurhash =
if (attachment.preview.contains("::")) {
attachment.preview.split("::").lastOrNull() ?: "" attachment.preview.split("::").lastOrNull() ?: ""
} else { } else {
attachment.preview attachment.preview
@@ -1049,14 +1119,20 @@ fun ReplyImagePreview(
try { try {
// Пробуем сначала из blob // Пробуем сначала из blob
if (attachment.blob.isNotEmpty()) { if (attachment.blob.isNotEmpty()) {
val decoded = try { val decoded =
val cleanBase64 = if (attachment.blob.contains(",")) { try {
val cleanBase64 =
if (attachment.blob.contains(",")) {
attachment.blob.substringAfter(",") attachment.blob.substringAfter(",")
} else { } else {
attachment.blob attachment.blob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) BitmapFactory.decodeByteArray(
decodedBytes,
0,
decodedBytes.size
)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -1067,7 +1143,8 @@ fun ReplyImagePreview(
} }
// Если blob нет - загружаем из локального файла // Если blob нет - загружаем из локального файла
val localBlob = AttachmentFileManager.readAttachment( val localBlob =
AttachmentFileManager.readAttachment(
context, context,
attachment.id, attachment.id,
senderPublicKey, senderPublicKey,
@@ -1075,14 +1152,20 @@ fun ReplyImagePreview(
) )
if (localBlob != null) { if (localBlob != null) {
val decoded = try { val decoded =
val cleanBase64 = if (localBlob.contains(",")) { try {
val cleanBase64 =
if (localBlob.contains(",")) {
localBlob.substringAfter(",") localBlob.substringAfter(",")
} else { } else {
localBlob localBlob
} }
val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) BitmapFactory.decodeByteArray(
decodedBytes,
0,
decodedBytes.size
)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -1096,8 +1179,8 @@ fun ReplyImagePreview(
} }
Box( Box(
modifier = modifier modifier =
.clip(RoundedCornerShape(4.dp)) modifier.clip(RoundedCornerShape(4.dp))
.background(Color.Gray.copy(alpha = 0.3f)) .background(Color.Gray.copy(alpha = 0.3f))
) { ) {
if (fullImageBitmap != null) { if (fullImageBitmap != null) {
@@ -1118,10 +1201,7 @@ fun ReplyImagePreview(
) )
} else { } else {
// Placeholder с иконкой только если нет ничего // Placeholder с иконкой только если нет ничего
Box( Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon( Icon(
TablerIcons.Photo, TablerIcons.Photo,
contentDescription = null, contentDescription = null,
@@ -1131,7 +1211,8 @@ fun ReplyImagePreview(
} }
} }
} }
}/** Profile photo menu for avatar */ }
/** Profile photo menu for avatar */
@Composable @Composable
fun ProfilePhotoMenu( fun ProfilePhotoMenu(
expanded: Boolean, expanded: Boolean,
@@ -1141,13 +1222,15 @@ fun ProfilePhotoMenu(
onDeletePhotoClick: (() -> Unit)? = null, onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false hasAvatar: Boolean = false
) { ) {
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) val dividerColor =
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.width(220.dp), modifier = Modifier.width(220.dp),
properties = PopupProperties( properties =
PopupProperties(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true dismissOnClickOutside = true
@@ -1164,8 +1247,8 @@ fun ProfilePhotoMenu(
// Показываем Delete только если есть аватар // Показываем Delete только если есть аватар
if (hasAvatar && onDeletePhotoClick != null) { if (hasAvatar && onDeletePhotoClick != null) {
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp) .height(0.5.dp)
.background(dividerColor) .background(dividerColor)
@@ -1192,13 +1275,15 @@ fun OtherProfileMenu(
onBlockClick: () -> Unit, onBlockClick: () -> Unit,
onClearChatClick: () -> Unit onClearChatClick: () -> Unit
) { ) {
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) val dividerColor =
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
modifier = Modifier.width(220.dp), modifier = Modifier.width(220.dp),
properties = PopupProperties( properties =
PopupProperties(
focusable = true, focusable = true,
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true dismissOnClickOutside = true
@@ -1213,8 +1298,8 @@ fun OtherProfileMenu(
) )
Box( Box(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp) .height(0.5.dp)
.background(dividerColor) .background(dividerColor)