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,
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)
)
}

View File

@@ -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)