feat: Enhance packet sending logic to handle connection issues and implement swipe-to-reply functionality in chat UI

This commit is contained in:
k1ngsterr1
2026-01-13 06:32:16 +05:00
parent 686adc1af2
commit 5bb9560353
5 changed files with 174 additions and 42 deletions

View File

@@ -267,14 +267,24 @@ class Protocol(
/** /**
* Send packet to server * Send packet to server
* Packets are queued if handshake is not complete * Packets are queued if handshake is not complete or if connection is down
* (как в Архиве - сохраняем пакеты при любых проблемах с соединением)
*/ */
fun sendPacket(packet: Packet) { fun sendPacket(packet: Packet) {
if (!handshakeComplete && packet !is PacketHandshake) { // Проверяем состояние соединения
log("📦 Queueing packet: ${packet.getPacketId()}") val socket = webSocket
val isConnected = _state.value == ProtocolState.AUTHENTICATED
// Добавляем в очередь если:
// 1. Handshake не завершён (кроме самого пакета handshake)
// 2. WebSocket не подключен или null
// 3. Не authenticated
if ((!handshakeComplete && packet !is PacketHandshake) || socket == null || !isConnected) {
log("📦 Queueing packet: ${packet.getPacketId()} (handshake=$handshakeComplete, socket=${socket != null}, state=${_state.value})")
packetQueue.add(packet) packetQueue.add(packet)
return return
} }
sendPacketDirect(packet) sendPacketDirect(packet)
} }
@@ -290,7 +300,9 @@ class Protocol(
val socket = webSocket val socket = webSocket
if (socket == null) { if (socket == null) {
log("❌ WebSocket is null, cannot send packet ${packet.getPacketId()}") log("❌ WebSocket is null, re-queueing packet ${packet.getPacketId()}")
// Как в Архиве - возвращаем пакет в очередь при ошибке
packetQueue.add(packet)
return return
} }
@@ -303,6 +315,9 @@ class Protocol(
} catch (e: Exception) { } catch (e: Exception) {
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}") log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
e.printStackTrace() e.printStackTrace()
// Как в Архиве - возвращаем пакет в очередь при ошибке отправки
log("📦 Re-queueing packet ${packet.getPacketId()} due to send error")
packetQueue.add(packet)
} }
} }

View File

@@ -9,8 +9,12 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -44,6 +48,9 @@ import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -230,14 +237,18 @@ fun ChatDetailScreen(
val replyMessages by viewModel.replyMessages.collectAsState() val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
// 🔥 FocusRequester для автофокуса на инпут при reply
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Дополнительная высота для reply панели (~50dp) // 🔥 Дополнительная высота для reply панели (~50dp)
val replyPanelHeight = if (hasReply) 50.dp else 0.dp val replyPanelHeight = if (hasReply) 50.dp else 0.dp
// Динамический bottom padding для списка: инпут (~70dp) + reply (~50dp) + клавиатура/эмодзи // Динамический bottom padding для списка: инпут (~70dp) + reply (~50dp) + клавиатура/эмодзи
// Одинаковый базовый отступ 70.dp для всех состояний
val listBottomPadding = when { val listBottomPadding = when {
isKeyboardVisible -> 70.dp + replyPanelHeight + imeHeight isKeyboardVisible -> 70.dp + replyPanelHeight + imeHeight
showEmojiPicker -> 70.dp + replyPanelHeight + emojiPanelHeight showEmojiPicker -> 70.dp + replyPanelHeight + emojiPanelHeight
else -> 100.dp + replyPanelHeight else -> 70.dp + replyPanelHeight // Было 100.dp, теперь одинаково для всех состояний
} }
// Telegram-style scroll tracking // Telegram-style scroll tracking
@@ -861,6 +872,10 @@ fun ChatDetailScreen(
selectedMessages + selectionKey selectedMessages + selectionKey
} }
} }
},
onSwipeToReply = {
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
viewModel.setReplyMessages(listOf(message))
} }
) )
} }
@@ -994,7 +1009,9 @@ fun ChatDetailScreen(
isBlocked = isBlocked, isBlocked = isBlocked,
// Emoji picker state (поднят для KeyboardAvoidingView) // Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker = showEmojiPicker, showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = { showEmojiPicker = it } onToggleEmojiPicker = { showEmojiPicker = it },
// Focus requester для автофокуса при reply
focusRequester = inputFocusRequester
) )
} }
@@ -1007,7 +1024,7 @@ fun ChatDetailScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.imePadding() .windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) { ) {
// Плоский контейнер как у инпута // Плоский контейнер как у инпута
Column( Column(
@@ -1360,8 +1377,24 @@ private fun MessageBubble(
showTail: Boolean = true, showTail: Boolean = true,
isSelected: Boolean = false, isSelected: Boolean = false,
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onClick: () -> Unit = {} onClick: () -> Unit = {},
onSwipeToReply: () -> Unit = {}
) { ) {
// 🔥 Swipe-to-reply state (как в Telegram)
var swipeOffset by remember { mutableStateOf(0f) }
val swipeThreshold = 80f // dp порог для активации reply
val maxSwipe = 120f // Максимальный сдвиг
// Анимация возврата
val animatedOffset by animateFloatAsState(
targetValue = swipeOffset,
animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f),
label = "swipeOffset"
)
// Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево
val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f)
// ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле // ❌ УБРАЛИ: Telegram-style enter animation - она мешает при скролле
// val (alpha, translationY) = rememberMessageEnterAnimation(message.id) // val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
@@ -1407,10 +1440,69 @@ private fun MessageBubble(
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Row( // 🔥 Swipe-to-reply wrapper
Box(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
// Если свайп достиг порога - активируем reply
if (swipeOffset <= -swipeThreshold) {
onSwipeToReply()
}
// Возвращаем на место
swipeOffset = 0f
},
onDragCancel = {
swipeOffset = 0f
},
onHorizontalDrag = { _, dragAmount ->
// Только свайп влево (отрицательный dragAmount)
val newOffset = swipeOffset + dragAmount
swipeOffset = newOffset.coerceIn(-maxSwipe, 0f)
}
)
}
) {
// 🔥 Reply icon (появляется справа при свайпе влево)
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 16.dp)
.graphicsLayer {
alpha = swipeProgress
scaleX = swipeProgress
scaleY = swipeProgress
}
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(
if (swipeProgress >= 1f) PrimaryBlue
else if (isDarkTheme) Color(0xFF3A3A3A)
else Color(0xFFE0E0E0)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Reply,
contentDescription = "Reply",
tint = if (swipeProgress >= 1f) Color.White
else if (isDarkTheme) Color.White.copy(alpha = 0.7f)
else Color(0xFF666666),
modifier = Modifier.size(20.dp)
)
}
}
Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 1.dp) .padding(horizontal = 8.dp, vertical = 1.dp)
.offset { IntOffset(animatedOffset.toInt(), 0) }
.graphicsLayer { .graphicsLayer {
// ❌ УБРАЛИ: alpha = alpha * selectionAlpha и translationY // ❌ УБРАЛИ: alpha = alpha * selectionAlpha и translationY
// Оставляем только selection анимацию // Оставляем только selection анимацию
@@ -1419,7 +1511,7 @@ private fun MessageBubble(
this.scaleY = selectionScale this.scaleY = selectionScale
}, },
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) { ) {
// Checkbox для выбранных сообщений // Checkbox для выбранных сообщений
AnimatedVisibility( AnimatedVisibility(
visible = isSelected, visible = isSelected,
@@ -1449,8 +1541,6 @@ private fun MessageBubble(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) )
// Тень только для исходящих
.then(if (message.isOutgoing) Modifier.shadow(elevation = 1.dp, shape = bubbleShape, clip = false) else Modifier)
.clip(bubbleShape) .clip(bubbleShape)
.background(bubbleColor) .background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 7.dp) .padding(horizontal = 12.dp, vertical = 7.dp)
@@ -1488,6 +1578,7 @@ private fun MessageBubble(
} }
} }
} }
} // End of swipe Box wrapper
} }
/** /**
@@ -1691,7 +1782,9 @@ private fun MessageInputBar(
isBlocked: Boolean = false, isBlocked: Boolean = false,
// Emoji picker state (поднят для KeyboardAvoidingView) // Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker: Boolean = false, showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {} onToggleEmojiPicker: (Boolean) -> Unit = {},
// Focus requester для автофокуса при reply
focusRequester: FocusRequester? = null
) { ) {
val hasReply = replyMessages.isNotEmpty() val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -1704,6 +1797,23 @@ private fun MessageInputBar(
val view = LocalView.current val view = LocalView.current
val density = LocalDensity.current val density = LocalDensity.current
// 🔥 Ссылка на EditText для программного фокуса
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
// 🔥 Автофокус при открытии reply панели
LaunchedEffect(hasReply, editTextView) {
if (hasReply) {
// Даём время на создание view если ещё null
kotlinx.coroutines.delay(50)
editTextView?.let { editText ->
editText.requestFocus()
// Открываем клавиатуру
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
}
}
}
// 🔥 Отслеживаем высоту клавиатуры (Telegram-style) // 🔥 Отслеживаем высоту клавиатуры (Telegram-style)
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val imeHeight = with(density) { imeInsets.getBottom(density).toDp() } val imeHeight = with(density) { imeInsets.getBottom(density).toDp() }
@@ -1739,11 +1849,8 @@ private fun MessageInputBar(
// Закрываем клавиатуру через IMM // Закрываем клавиатуру через IMM
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
// Небольшая задержка перед показом эмодзи для плавного перехода // Показываем эмодзи сразу без задержки (эмодзи уже предзагружены)
scope.launch { onToggleEmojiPicker(true)
delay(50)
onToggleEmojiPicker(true)
}
} }
} }
@@ -1760,7 +1867,7 @@ private fun MessageInputBar(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.imePadding() .windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) { ) {
// Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут) // Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут)
if (isBlocked) { if (isBlocked) {
@@ -1992,8 +2099,8 @@ private fun MessageInputBar(
// Мгновенно когда клавиатура открывается // Мгновенно когда клавиатура открывается
snap() snap()
} else { } else {
// Плавно когда открываем/закрываем эмодзи без клавиатуры // Быстрая анимация для мгновенного отклика (как в Telegram)
tween(durationMillis = 200, easing = FastOutSlowInEasing) tween(durationMillis = 150, easing = TelegramEasing)
}, },
label = "EmojiPanelHeight" label = "EmojiPanelHeight"
) )
@@ -2004,7 +2111,8 @@ private fun MessageInputBar(
.height(animatedHeight) .height(animatedHeight)
.clipToBounds() .clipToBounds()
) { ) {
if (showEmojiPicker && !isKeyboardVisible) { // 🚀 Рендерим панель только когда нужно
if (showEmojiPicker && !isKeyboardVisible && animatedHeight > 0.dp) {
AppleEmojiPickerPanel( AppleEmojiPickerPanel(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji -> onEmojiSelected = { emoji ->

View File

@@ -876,12 +876,11 @@ fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit)
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( // 🔥 Используем AppleEmojiText для отображения эмодзи
AppleEmojiText(
text = dialog.lastMessage.ifEmpty { "No messages" }, text = dialog.lastMessage.ifEmpty { "No messages" },
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )

View File

@@ -206,7 +206,8 @@ fun AppleEmojiTextField(
textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White, textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.White,
textSize: Float = 16f, textSize: Float = 16f,
hint: String = "Message", hint: String = "Message",
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null
) { ) {
AndroidView( AndroidView(
factory = { ctx -> factory = { ctx ->
@@ -219,6 +220,8 @@ fun AppleEmojiTextField(
// Убираем все возможные фоны у EditText // Убираем все возможные фоны у EditText
background = null background = null
setBackgroundColor(android.graphics.Color.TRANSPARENT) setBackgroundColor(android.graphics.Color.TRANSPARENT)
// Уведомляем о создании view
onViewCreated?.invoke(this)
} }
}, },
update = { view -> update = { view ->

View File

@@ -151,7 +151,10 @@ object EmojiCache {
// Предзагрузка при старте приложения (вызывать из Application или MainActivity) // Предзагрузка при старте приложения (вызывать из Application или MainActivity)
fun preload(context: Context) { fun preload(context: Context) {
if (allEmojis != null) return if (allEmojis != null) {
isLoaded = true
return
}
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
loadEmojisInternal(context) loadEmojisInternal(context)
} }
@@ -356,19 +359,23 @@ fun AppleEmojiPickerPanel(
var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) } var selectedCategory by remember { mutableStateOf(EMOJI_CATEGORIES[0]) }
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
// Загружаем эмодзи если еще не загружены (без задержки если уже в кеше) // Загружаем эмодзи если еще не загружены (синхронно из кеша если уже загружено)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!EmojiCache.isLoaded) { if (!EmojiCache.isLoaded) {
EmojiCache.loadEmojis(context) kotlinx.coroutines.launch(kotlinx.coroutines.Dispatchers.Main) {
EmojiCache.loadEmojis(context)
}
} }
} }
// Текущие эмодзи для выбранной категории // Текущие эмодзи для выбранной категории - используем derivedStateOf для оптимизации
val currentEmojis = remember(selectedCategory.key, EmojiCache.isLoaded) { val currentEmojis by remember {
if (EmojiCache.isLoaded) { derivedStateOf {
EmojiCache.getEmojisForCategory(selectedCategory.key) if (EmojiCache.isLoaded) {
} else { EmojiCache.getEmojisForCategory(selectedCategory.key)
emptyList() } else {
emptyList()
}
} }
} }
@@ -390,10 +397,10 @@ fun AppleEmojiPickerPanel(
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(categoryBarBackground) .background(categoryBarBackground),
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp)
) { ) {
items(EMOJI_CATEGORIES) { category -> items(EMOJI_CATEGORIES) { category ->
CategoryButton( CategoryButton(
@@ -446,15 +453,15 @@ fun AppleEmojiPickerPanel(
columns = GridCells.Fixed(8), columns = GridCells.Fixed(8),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f) .weight(1f),
.padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(1.dp), horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalArrangement = Arrangement.spacedBy(1.dp), verticalArrangement = Arrangement.spacedBy(1.dp),
contentPadding = PaddingValues(vertical = 4.dp) contentPadding = PaddingValues(horizontal = 12.dp, top = 4.dp, bottom = 16.dp)
) { ) {
items( items(
items = currentEmojis, items = currentEmojis,
key = { it } key = { emoji -> emoji },
contentType = { "emoji" }
) { unified -> ) { unified ->
EmojiButton( EmojiButton(
unified = unified, unified = unified,