feat: Add delivery confirmation for incoming messages in ProtocolManager

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

View File

@@ -69,6 +69,7 @@ dependencies {
// Coil for image loading // Coil for image loading
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
// Crypto libraries for key generation // Crypto libraries for key generation
implementation("org.bitcoinj:bitcoinj-core:0.16.2") implementation("org.bitcoinj:bitcoinj-core:0.16.2")

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,15 @@ object ProtocolManager {
val messagePacket = packet as PacketMessage val messagePacket = packet as PacketMessage
addLog("📩 Incoming message from ${messagePacket.fromPublicKey.take(16)}...") 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 { scope.launch {
messageRepository?.handleIncomingMessage(messagePacket) messageRepository?.handleIncomingMessage(messagePacket)
} }

View File

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

View File

@@ -226,12 +226,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Обновляем диалог // Обновляем диалог
saveDialog(decryptedText, packet.timestamp) saveDialog(decryptedText, packet.timestamp)
// Отправляем подтверждение доставки // ⚠️ Delivery отправляется в ProtocolManager.setupPacketHandlers()
val deliveryPacket = PacketDelivery().apply { // Не отправляем повторно чтобы избежать дублирования!
toPublicKey = packet.fromPublicKey
messageId = packet.messageId // 👁️ Сразу отправляем read receipt (как в Telegram - сообщения прочитаны если чат открыт)
delay(100) // Небольшая задержка для естественности
withContext(Dispatchers.Main) {
sendReadReceipt(packet.messageId, packet.fromPublicKey)
} }
ProtocolManager.send(deliveryPacket)
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog("❌ Error handling incoming message: ${e.message}") 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
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 com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/** /**
* Компонент отображения результатов поиска пользователей Аналогичен результатам поиска в React * Компонент отображения результатов поиска пользователей
* Native приложении * Минималистичный дизайн с плавными анимациями
*/ */
@Composable @Composable
fun SearchResultsList( fun SearchResultsList(
@@ -41,50 +44,107 @@ fun SearchResultsList(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Loading state
AnimatedVisibility( AnimatedVisibility(
visible = isSearching, visible = isSearching,
enter = fadeIn(animationSpec = tween(durationMillis = 200)), enter = fadeIn(animationSpec = tween(200)),
exit = fadeOut(animationSpec = tween(durationMillis = 200)) exit = fadeOut(animationSpec = tween(150))
) {
Box(
modifier = Modifier
.fillMaxSize()
.imePadding(), // Поднимается при клавиатуре
contentAlignment = Alignment.Center
) { ) {
// Индикатор загрузки в центре
Column( Column(
modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue, color = PrimaryBlue,
strokeWidth = 3.dp strokeWidth = 2.5.dp
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text(text = "Searching...", fontSize = 14.sp, color = secondaryTextColor)
}
}
AnimatedVisibility(
visible = !isSearching && searchResults.isEmpty(),
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
exit = fadeOut(animationSpec = tween(durationMillis = 200))
) {
// Подсказка в центре экрана
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Text(
text = "Search by username or public key", text = "Searching...",
fontSize = 15.sp, fontSize = 14.sp,
color = secondaryTextColor color = secondaryTextColor
) )
} }
} }
}
// Empty state - поднимается при открытии клавиатуры
AnimatedVisibility(
visible = !isSearching && searchResults.isEmpty(),
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
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( AnimatedVisibility(
visible = !isSearching && searchResults.isNotEmpty(), visible = !isSearching && searchResults.isNotEmpty(),
enter = fadeIn(animationSpec = tween(durationMillis = 300)), enter = fadeIn(animationSpec = tween(250)),
exit = fadeOut(animationSpec = tween(durationMillis = 200)) exit = fadeOut(animationSpec = tween(150))
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp)
) { ) {
// Список результатов без серой плашки
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(searchResults) { index, user -> itemsIndexed(searchResults) { index, 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( SearchResultItem(
user = user, user = user,
isOwnAccount = user.publicKey == currentUserPublicKey, isOwnAccount = user.publicKey == currentUserPublicKey,
@@ -96,9 +156,10 @@ fun SearchResultsList(
} }
} }
} }
}
} }
/** Элемент результата поиска - пользователь */ /** Элемент результата поиска - минималистичный дизайн */
@Composable @Composable
private fun SearchResultItem( private fun SearchResultItem(
user: SearchUser, user: SearchUser,
@@ -109,32 +170,28 @@ private fun SearchResultItem(
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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 = val avatarColors = getAvatarColor(
getAvatarColor(
if (isOwnAccount) "SavedMessages" else user.publicKey, if (isOwnAccount) "SavedMessages" else user.publicKey,
isDarkTheme isDarkTheme
) )
Column { Column {
Row( Row(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Аватар // Avatar - clean and simple
Box( Box(
modifier = modifier = Modifier
Modifier.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor),
if (isOwnAccount) PrimaryBlue
else avatarColors.backgroundColor
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (isOwnAccount) { if (isOwnAccount) {
@@ -146,14 +203,13 @@ private fun SearchResultItem(
) )
} else { } else {
Text( Text(
text = text = if (user.title.isNotEmpty()) {
if (user.title.isNotEmpty()) {
getInitials(user.title) getInitials(user.title)
} else { } else {
user.publicKey.take(2).uppercase() user.publicKey.take(2).uppercase()
}, },
fontSize = 14.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
color = avatarColors.textColor color = avatarColors.textColor
) )
} }
@@ -161,28 +217,23 @@ private fun SearchResultItem(
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
// Информация о пользователе // User info
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Имя и значок верификации // Name and verification badge
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
Text( Text(
text = text = if (isOwnAccount) "Saved Messages"
if (isOwnAccount) { else user.title.ifEmpty { user.publicKey.take(10) },
"Saved Messages"
} else {
user.title.ifEmpty { user.publicKey.take(10) }
},
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Medium,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
// Значок верификации
if (!isOwnAccount && user.verified > 0) { if (!isOwnAccount && user.verified > 0) {
VerifiedBadge(verified = user.verified, size = 16) VerifiedBadge(verified = user.verified, size = 16)
} }
@@ -190,14 +241,10 @@ private fun SearchResultItem(
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
// Юзернейм или публичный ключ // Username
Text( Text(
text = text = if (isOwnAccount) "Notes"
if (isOwnAccount) { else "@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}",
"Notes"
} else {
"@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}"
},
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
@@ -206,10 +253,10 @@ private fun SearchResultItem(
} }
} }
// Разделитель между элементами // Simple divider
if (!isLastItem) { if (!isLastItem) {
Divider( Divider(
modifier = Modifier.padding(start = 80.dp), modifier = Modifier.padding(start = 76.dp),
color = dividerColor, color = dividerColor,
thickness = 0.5.dp 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)
)
}

File diff suppressed because one or more lines are too long