feat: Add delivery confirmation for incoming messages in ProtocolManager

This commit is contained in:
k1ngsterr1
2026-01-12 14:47:36 +05:00
parent a7976c7cf3
commit 99121ce996
11 changed files with 413 additions and 153 deletions

View File

@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -220,29 +221,38 @@ fun MainScreen(
// Навигация между экранами
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
// Анимированный переход между чатами
// Анимированный переход между чатами - плавный crossfade без прыжков
AnimatedContent(
targetState = selectedUser,
transitionSpec = {
if (targetState != null) {
// Открытие чата - слайд слева
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(300)
) togetherWith slideOutHorizontally(
targetOffsetX = { -it / 3 },
animationSpec = tween(300)
// Плавный crossfade для избежания "прыжков" header'а
val enterAnim = fadeIn(
animationSpec = tween(
durationMillis = 250,
easing = FastOutSlowInEasing
)
} else {
// Закрытие чата - слайд справа
slideInHorizontally(
initialOffsetX = { -it / 3 },
animationSpec = tween(300)
) togetherWith slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(300)
) + slideInHorizontally(
initialOffsetX = { if (targetState != null) it / 4 else -it / 4 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
}
)
val exitAnim = fadeOut(
animationSpec = tween(
durationMillis = 200,
easing = FastOutSlowInEasing
)
) + slideOutHorizontally(
targetOffsetX = { if (targetState != null) -it / 6 else it / 6 },
animationSpec = tween(
durationMillis = 300,
easing = FastOutSlowInEasing
)
)
enterAnim togetherWith exitAnim using SizeTransform(clip = false)
},
label = "chatNavigation"
) { user ->

View File

@@ -364,6 +364,7 @@ class PacketRead : Packet() {
/**
* Delivery packet (ID: 0x08)
* Уведомление о доставке сообщения
* Порядок полей как в React Native: toPublicKey, messageId
*/
class PacketDelivery : Packet() {
var messageId: String = ""
@@ -372,15 +373,17 @@ class PacketDelivery : Packet() {
override fun getPacketId(): Int = 0x08
override fun receive(stream: Stream) {
messageId = stream.readString()
// React Native читает: toPublicKey, messageId
toPublicKey = stream.readString()
messageId = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(messageId)
// React Native пишет: toPublicKey, messageId
stream.writeString(toPublicKey)
stream.writeString(messageId)
return stream
}
}

View File

@@ -43,6 +43,7 @@ class Protocol(
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS) // Автоматический ping/pong для keep-alive
.build()
private var webSocket: WebSocket? = null
@@ -59,8 +60,8 @@ class Protocol(
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Packet waiters - callbacks for specific packet types
private val packetWaiters = mutableMapOf<Int, MutableList<(Packet) -> Unit>>()
// Packet waiters - callbacks for specific packet types (thread-safe)
private val packetWaiters = java.util.concurrent.ConcurrentHashMap<Int, MutableList<(Packet) -> Unit>>()
// Packet queue for packets sent before handshake complete
private val packetQueue = mutableListOf<Packet>()
@@ -104,7 +105,7 @@ class Protocol(
/**
* Start heartbeat to keep connection alive
* Как в Архиве - отправляем text "heartbeat"
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
*/
private fun startHeartbeat(intervalSeconds: Int) {
heartbeatJob?.cancel()
@@ -113,18 +114,36 @@ class Protocol(
log("💓 Starting heartbeat with interval: ${intervalSeconds}s (sending every ${intervalMs}ms)")
heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
sendHeartbeat()
while (isActive) {
delay(intervalMs)
try {
if (webSocket?.send("heartbeat") == true) {
log("💓 Heartbeat sent")
} else {
log("💔 Heartbeat failed - socket closed or null")
sendHeartbeat()
}
}
}
/**
* Отправка heartbeat с проверкой состояния
*/
private fun sendHeartbeat() {
try {
if (_state.value == ProtocolState.AUTHENTICATED) {
val sent = webSocket?.send("heartbeat") ?: false
if (sent) {
log("💓 Heartbeat sent")
} else {
log("💔 Heartbeat failed - socket closed or null")
// Триггерим reconnect если heartbeat не прошёл
if (!isManuallyClosed) {
log("🔄 Triggering reconnect due to failed heartbeat")
handleDisconnect()
}
} catch (e: Exception) {
log("💔 Heartbeat error: ${e.message}")
}
}
} catch (e: Exception) {
log("💔 Heartbeat error: ${e.message}")
}
}

View File

@@ -62,6 +62,15 @@ object ProtocolManager {
val messagePacket = packet as PacketMessage
addLog("📩 Incoming message from ${messagePacket.fromPublicKey.take(16)}...")
// ⚡ ВАЖНО: Отправляем подтверждение доставки обратно серверу
// Без этого сервер не будет отправлять следующие сообщения!
val deliveryPacket = PacketDelivery().apply {
messageId = messagePacket.messageId
toPublicKey = messagePacket.fromPublicKey
}
send(deliveryPacket)
addLog("✅ Sent delivery confirmation for message ${messagePacket.messageId.take(16)}...")
scope.launch {
messageRepository?.handleIncomingMessage(messagePacket)
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
@@ -907,7 +908,7 @@ fun ChatDetailScreen(
}
// 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений)
// Стеклянный стиль как у инпута
// Стеклянный стиль как у инпута с блюром
AnimatedVisibility(
visible = isSelectionMode,
enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }),
@@ -915,40 +916,46 @@ fun ChatDetailScreen(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.navigationBarsPadding()
) {
// Glass container
// Glass container с эффектом блюра
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.clip(RoundedCornerShape(24.dp))
.background(
if (isDarkTheme) Color(0xFF2A2A2A).copy(alpha = 0.85f)
else Color(0xFFF2F3F5).copy(alpha = 0.92f)
if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.7f)
else Color(0xFFF8F9FA).copy(alpha = 0.8f)
)
.border(
width = 0.5.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.06f),
shape = RoundedCornerShape(20.dp)
width = 1.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.12f)
else Color.Black.copy(alpha = 0.08f),
shape = RoundedCornerShape(24.dp)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
.padding(vertical = 10.dp, horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Reply button - стеклянная кнопка
// Reply button - стеклянная кнопка с блюром
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(16.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.04f)
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
else Color.Black.copy(alpha = 0.06f)
)
.border(
width = 0.5.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f)
else Color.Black.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp)
)
.clickable {
val selectedMsgs = messages
@@ -957,7 +964,7 @@ fun ChatDetailScreen(
viewModel.setReplyMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 12.dp),
.padding(vertical = 14.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Row(
@@ -975,19 +982,25 @@ fun ChatDetailScreen(
"Reply",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
}
// Forward button - стеклянная кнопка
// Forward button - стеклянная кнопка с блюром
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(16.dp))
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color.Black.copy(alpha = 0.04f)
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
else Color.Black.copy(alpha = 0.06f)
)
.border(
width = 0.5.dp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f)
else Color.Black.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp)
)
.clickable {
val selectedMsgs = messages
@@ -996,7 +1009,7 @@ fun ChatDetailScreen(
viewModel.setForwardMessages(selectedMsgs)
selectedMessages = emptySet()
}
.padding(vertical = 12.dp),
.padding(vertical = 14.dp, horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Row(
@@ -1014,7 +1027,7 @@ fun ChatDetailScreen(
"Forward",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
}
@@ -1283,19 +1296,9 @@ private fun MessageBubble(
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(3.dp))
Icon(
when (message.status) {
MessageStatus.SENDING -> Icons.Default.Schedule
MessageStatus.SENT -> Icons.Default.Done
MessageStatus.DELIVERED -> Icons.Default.DoneAll
MessageStatus.READ -> Icons.Default.DoneAll
},
contentDescription = null,
tint =
if (message.status == MessageStatus.READ)
Color(0xFF4FC3F7) // Голубые галочки как в Telegram
else timeColor,
modifier = Modifier.size(16.dp)
AnimatedMessageStatus(
status = message.status,
timeColor = timeColor
)
}
}
@@ -1304,6 +1307,65 @@ private fun MessageBubble(
}
}
/**
* 🎯 Анимированный статус сообщения с плавными переходами
*/
@Composable
private fun AnimatedMessageStatus(
status: MessageStatus,
timeColor: Color
) {
// Цвет с анимацией
val targetColor = if (status == MessageStatus.READ) Color(0xFF4FC3F7) else timeColor
val animatedColor by animateColorAsState(
targetValue = targetColor,
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "statusColor"
)
// Анимация scale для эффекта "pop"
var previousStatus by remember { mutableStateOf(status) }
var shouldAnimate by remember { mutableStateOf(false) }
LaunchedEffect(status) {
if (previousStatus != status) {
shouldAnimate = true
previousStatus = status
}
}
val scale by animateFloatAsState(
targetValue = if (shouldAnimate) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
finishedListener = { shouldAnimate = false },
label = "statusScale"
)
// Crossfade для плавной смены иконки
Crossfade(
targetState = status,
animationSpec = tween(durationMillis = 200),
label = "statusIcon"
) { currentStatus ->
Icon(
imageVector = when (currentStatus) {
MessageStatus.SENDING -> Icons.Default.Schedule
MessageStatus.SENT -> Icons.Default.Done
MessageStatus.DELIVERED -> Icons.Default.DoneAll
MessageStatus.READ -> Icons.Default.DoneAll
},
contentDescription = null,
tint = animatedColor,
modifier = Modifier
.size(16.dp)
.scale(scale)
)
}
}
/** 🚀 Разделитель даты с fade-in анимацией */
@Composable
private fun DateHeader(dateText: String, secondaryTextColor: Color) {

View File

@@ -226,12 +226,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Обновляем диалог
saveDialog(decryptedText, packet.timestamp)
// Отправляем подтверждение доставки
val deliveryPacket = PacketDelivery().apply {
toPublicKey = packet.fromPublicKey
messageId = packet.messageId
// ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers()
// Не отправляем повторно чтобы избежать дублирования!
// 👁️ Сразу отправляем read receipt (как в Telegram - сообщения прочитаны если чат открыт)
delay(100) // Небольшая задержка для естественности
withContext(Dispatchers.Main) {
sendReadReceipt(packet.messageId, packet.fromPublicKey)
}
ProtocolManager.send(deliveryPacket)
} catch (e: Exception) {
ProtocolManager.addLog("❌ Error handling incoming message: ${e.message}")

View File

@@ -16,17 +16,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* Компонент отображения результатов поиска пользователей Аналогичен результатам поиска в React
* Native приложении
* Компонент отображения результатов поиска пользователей
* Минималистичный дизайн с плавными анимациями
*/
@Composable
fun SearchResultsList(
@@ -41,64 +44,122 @@ fun SearchResultsList(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Box(modifier = modifier.fillMaxSize()) {
// Loading state
AnimatedVisibility(
visible = isSearching,
enter = fadeIn(animationSpec = tween(durationMillis = 200)),
exit = fadeOut(animationSpec = tween(durationMillis = 200))
enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(150))
) {
// Индикатор загрузки в центре
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
Box(
modifier = Modifier
.fillMaxSize()
.imePadding(), // Поднимается при клавиатуре
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue,
strokeWidth = 3.dp
)
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Searching...", fontSize = 14.sp, color = secondaryTextColor)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = PrimaryBlue,
strokeWidth = 2.5.dp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Searching...",
fontSize = 14.sp,
color = secondaryTextColor
)
}
}
}
// Empty state - поднимается при открытии клавиатуры
AnimatedVisibility(
visible = !isSearching && searchResults.isEmpty(),
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
exit = fadeOut(animationSpec = tween(durationMillis = 200))
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
// Подсказка в центре экрана
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Search by username or public key",
fontSize = 15.sp,
color = secondaryTextColor
)
Box(
modifier = Modifier
.fillMaxSize()
.imePadding(), // Поднимается при клавиатуре
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// Lottie search animation
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.search))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Search for users",
fontSize = 17.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Enter username or public key",
fontSize = 14.sp,
color = secondaryTextColor
)
}
}
}
// Results list with staggered animation
AnimatedVisibility(
visible = !isSearching && searchResults.isNotEmpty(),
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
exit = fadeOut(animationSpec = tween(durationMillis = 200))
enter = fadeIn(animationSpec = tween(250)),
exit = fadeOut(animationSpec = tween(150))
) {
// Список результатов без серой плашки
LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
itemsIndexed(searchResults) { index, user ->
SearchResultItem(
user = user,
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
onClick = { onUserClick(user) }
)
// Staggered animation
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(index * 40L)
isVisible = true
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(tween(200)) + slideInHorizontally(
initialOffsetX = { it / 3 },
animationSpec = tween(250, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(100))
) {
SearchResultItem(
user = user,
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
onClick = { onUserClick(user) }
)
}
}
}
}
}
}
/** Элемент результата поиска - пользователь */
/** Элемент результата поиска - минималистичный дизайн */
@Composable
private fun SearchResultItem(
user: SearchUser,
@@ -109,32 +170,28 @@ private fun SearchResultItem(
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
// Получаем цвета аватара - используем publicKey для консистентности
val avatarColors =
getAvatarColor(
if (isOwnAccount) "SavedMessages" else user.publicKey,
isDarkTheme
)
// Получаем цвета аватара
val avatarColors = getAvatarColor(
if (isOwnAccount) "SavedMessages" else user.publicKey,
isDarkTheme
)
Column {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Аватар
// Avatar - clean and simple
Box(
modifier =
Modifier.size(48.dp)
.clip(CircleShape)
.background(
if (isOwnAccount) PrimaryBlue
else avatarColors.backgroundColor
),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (isOwnAccount) {
@@ -146,14 +203,13 @@ private fun SearchResultItem(
)
} else {
Text(
text =
if (user.title.isNotEmpty()) {
text = if (user.title.isNotEmpty()) {
getInitials(user.title)
} else {
user.publicKey.take(2).uppercase()
},
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = avatarColors.textColor
)
}
@@ -161,28 +217,23 @@ private fun SearchResultItem(
Spacer(modifier = Modifier.width(12.dp))
// Информация о пользователе
// User info
Column(modifier = Modifier.weight(1f)) {
// Имя и значок верификации
// Name and verification badge
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text =
if (isOwnAccount) {
"Saved Messages"
} else {
user.title.ifEmpty { user.publicKey.take(10) }
},
text = if (isOwnAccount) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) },
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Medium,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Значок верификации
if (!isOwnAccount && user.verified > 0) {
VerifiedBadge(verified = user.verified, size = 16)
}
@@ -190,14 +241,10 @@ private fun SearchResultItem(
Spacer(modifier = Modifier.height(2.dp))
// Юзернейм или публичный ключ
// Username
Text(
text =
if (isOwnAccount) {
"Notes"
} else {
"@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}"
},
text = if (isOwnAccount) "Notes"
else "@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}",
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
@@ -206,12 +253,12 @@ private fun SearchResultItem(
}
}
// Разделитель между элементами
// Simple divider
if (!isLastItem) {
Divider(
modifier = Modifier.padding(start = 80.dp),
color = dividerColor,
thickness = 0.5.dp
modifier = Modifier.padding(start = 76.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}

View File

@@ -0,0 +1,105 @@
package com.rosetta.messenger.ui.components
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.compose.*
/**
* Компонент для отображения анимированных эмодзи Telegram в формате TGS
*
* TGS - это Lottie анимации (JSON), сжатые через gzip.
* Библиотека Lottie автоматически распаковывает и проигрывает их.
*
* @param tgsFileName Имя файла из папки assets/emoji (например: "5431456783947234.tgs")
* @param modifier Модификатор для кастомизации размера и других параметров
* @param size Размер эмодзи (по умолчанию 32.dp)
* @param iterations Количество повторений анимации (LottieConstants.IterateForever для бесконечного)
* @param speed Скорость воспроизведения анимации (1.0f = нормальная скорость)
*/
@Composable
fun TelegramAnimatedEmoji(
tgsFileName: String,
modifier: Modifier = Modifier,
size: Dp = 32.dp,
iterations: Int = LottieConstants.IterateForever,
speed: Float = 1.0f
) {
// Путь к файлу в assets/emoji/
val animationPath = "emoji/$tgsFileName"
// Загружаем анимацию из assets
val composition by rememberLottieComposition(
LottieCompositionSpec.Asset(animationPath)
)
// Настраиваем воспроизведение
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = iterations,
speed = speed,
restartOnPlay = true
)
// Отображаем Lottie анимацию
LottieAnimation(
composition = composition,
progress = { progress },
modifier = modifier.size(size)
)
}
/**
* Упрощенный вариант для быстрого использования с ID документа
*
* @param documentId ID документа из Telegram (например: 5431456783947234)
*/
@Composable
fun TelegramAnimatedEmojiById(
documentId: Long,
modifier: Modifier = Modifier,
size: Dp = 32.dp
) {
TelegramAnimatedEmoji(
tgsFileName = "$documentId.tgs",
modifier = modifier,
size = size
)
}
/**
* Вариант с возможностью остановки/запуска анимации
*
* @param isPlaying Управление воспроизведением анимации
*/
@Composable
fun TelegramAnimatedEmojiPlayable(
tgsFileName: String,
isPlaying: Boolean = true,
modifier: Modifier = Modifier,
size: Dp = 32.dp,
speed: Float = 1.0f
) {
val animationPath = "emoji/$tgsFileName"
val composition by rememberLottieComposition(
LottieCompositionSpec.Asset(animationPath)
)
val progress by animateLottieCompositionAsState(
composition = composition,
isPlaying = isPlaying,
iterations = LottieConstants.IterateForever,
speed = speed,
restartOnPlay = true
)
LottieAnimation(
composition = composition,
progress = { progress },
modifier = modifier.size(size)
)
}