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