feat: Add delivery confirmation for incoming messages in ProtocolManager
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
1
app/src/main/assets/lottie/search.json
Normal file
1
app/src/main/assets/lottie/search.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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 ->
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +114,36 @@ 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)
|
||||||
try {
|
sendHeartbeat()
|
||||||
if (webSocket?.send("heartbeat") == true) {
|
}
|
||||||
log("💓 Heartbeat sent")
|
}
|
||||||
} else {
|
}
|
||||||
log("💔 Heartbeat failed - socket closed or null")
|
|
||||||
|
/**
|
||||||
|
* Отправка 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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +1296,9 @@ private fun MessageBubble(
|
|||||||
)
|
)
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
Spacer(modifier = Modifier.width(3.dp))
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
Icon(
|
AnimatedMessageStatus(
|
||||||
when (message.status) {
|
status = message.status,
|
||||||
MessageStatus.SENDING -> Icons.Default.Schedule
|
timeColor = timeColor
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 анимацией */
|
/** 🚀 Разделитель даты с fade-in анимацией */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
private fun DateHeader(dateText: String, secondaryTextColor: Color) {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,64 +44,122 @@ 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(
|
||||||
Column(
|
modifier = Modifier
|
||||||
modifier = Modifier.fillMaxSize(),
|
.fillMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.imePadding(), // Поднимается при клавиатуре
|
||||||
verticalArrangement = Arrangement.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
Column(
|
||||||
modifier = Modifier.size(32.dp),
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue,
|
) {
|
||||||
strokeWidth = 3.dp
|
CircularProgressIndicator(
|
||||||
)
|
modifier = Modifier.size(32.dp),
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
color = PrimaryBlue,
|
||||||
Text(text = "Searching...", fontSize = 14.sp, color = secondaryTextColor)
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Searching...",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty state - поднимается при открытии клавиатуры
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !isSearching && searchResults.isEmpty(),
|
visible = !isSearching && searchResults.isEmpty(),
|
||||||
enter = fadeIn(animationSpec = tween(durationMillis = 300)),
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
exit = fadeOut(animationSpec = tween(durationMillis = 200))
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
) {
|
) {
|
||||||
// Подсказка в центре экрана
|
Box(
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxSize()
|
||||||
text = "Search by username or public key",
|
.imePadding(), // Поднимается при клавиатуре
|
||||||
fontSize = 15.sp,
|
contentAlignment = Alignment.Center
|
||||||
color = secondaryTextColor
|
) {
|
||||||
)
|
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(
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
|
) {
|
||||||
itemsIndexed(searchResults) { index, user ->
|
itemsIndexed(searchResults) { index, user ->
|
||||||
SearchResultItem(
|
// Staggered animation
|
||||||
user = user,
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
isOwnAccount = user.publicKey == currentUserPublicKey,
|
LaunchedEffect(Unit) {
|
||||||
isDarkTheme = isDarkTheme,
|
kotlinx.coroutines.delay(index * 40L)
|
||||||
isLastItem = index == searchResults.size - 1,
|
isVisible = true
|
||||||
onClick = { onUserClick(user) }
|
}
|
||||||
)
|
|
||||||
|
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
|
@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,12 +253,12 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
1
app/src/main/res/raw/search.json
Normal file
1
app/src/main/res/raw/search.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user