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

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