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
* 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) {
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)
return
}
sendPacketDirect(packet)
}
@@ -290,7 +300,9 @@ class Protocol(
val socket = webSocket
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
}
@@ -303,6 +315,9 @@ class Protocol(
} catch (e: Exception) {
log("❌ Exception sending packet ${packet.getPacketId()}: ${e.message}")
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.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
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.grid.GridCells
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.NestedScrollSource
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.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -230,14 +237,18 @@ fun ChatDetailScreen(
val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty()
// 🔥 FocusRequester для автофокуса на инпут при reply
val inputFocusRequester = remember { FocusRequester() }
// 🔥 Дополнительная высота для reply панели (~50dp)
val replyPanelHeight = if (hasReply) 50.dp else 0.dp
// Динамический bottom padding для списка: инпут (~70dp) + reply (~50dp) + клавиатура/эмодзи
// Одинаковый базовый отступ 70.dp для всех состояний
val listBottomPadding = when {
isKeyboardVisible -> 70.dp + replyPanelHeight + imeHeight
showEmojiPicker -> 70.dp + replyPanelHeight + emojiPanelHeight
else -> 100.dp + replyPanelHeight
else -> 70.dp + replyPanelHeight // Было 100.dp, теперь одинаково для всех состояний
}
// Telegram-style scroll tracking
@@ -861,6 +872,10 @@ fun ChatDetailScreen(
selectedMessages + selectionKey
}
}
},
onSwipeToReply = {
// 🔥 Swipe-to-reply: добавляем это сообщение в reply
viewModel.setReplyMessages(listOf(message))
}
)
}
@@ -994,7 +1009,9 @@ fun ChatDetailScreen(
isBlocked = isBlocked,
// Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker = showEmojiPicker,
onToggleEmojiPicker = { showEmojiPicker = it }
onToggleEmojiPicker = { showEmojiPicker = it },
// Focus requester для автофокуса при reply
focusRequester = inputFocusRequester
)
}
@@ -1007,7 +1024,7 @@ fun ChatDetailScreen(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
.windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) {
// Плоский контейнер как у инпута
Column(
@@ -1360,8 +1377,24 @@ private fun MessageBubble(
showTail: Boolean = true,
isSelected: Boolean = false,
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 - она мешает при скролле
// val (alpha, translationY) = rememberMessageEnterAnimation(message.id)
@@ -1406,11 +1439,70 @@ private fun MessageBubble(
)
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
// 🔥 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(
Row(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 1.dp)
.offset { IntOffset(animatedOffset.toInt(), 0) }
.graphicsLayer {
// ❌ УБРАЛИ: alpha = alpha * selectionAlpha и translationY
// Оставляем только selection анимацию
@@ -1419,7 +1511,7 @@ private fun MessageBubble(
this.scaleY = selectionScale
},
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
) {
) {
// Checkbox для выбранных сообщений
AnimatedVisibility(
visible = isSelected,
@@ -1449,8 +1541,6 @@ private fun MessageBubble(
onClick = onClick,
onLongClick = onLongClick
)
// Тень только для исходящих
.then(if (message.isOutgoing) Modifier.shadow(elevation = 1.dp, shape = bubbleShape, clip = false) else Modifier)
.clip(bubbleShape)
.background(bubbleColor)
.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,
// Emoji picker state (поднят для KeyboardAvoidingView)
showEmojiPicker: Boolean = false,
onToggleEmojiPicker: (Boolean) -> Unit = {}
onToggleEmojiPicker: (Boolean) -> Unit = {},
// Focus requester для автофокуса при reply
focusRequester: FocusRequester? = null
) {
val hasReply = replyMessages.isNotEmpty()
val keyboardController = LocalSoftwareKeyboardController.current
@@ -1703,6 +1796,23 @@ private fun MessageInputBar(
val context = LocalContext.current
val view = LocalView.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)
val imeInsets = WindowInsets.ime
@@ -1739,11 +1849,8 @@ private fun MessageInputBar(
// Закрываем клавиатуру через IMM
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
// Небольшая задержка перед показом эмодзи для плавного перехода
scope.launch {
delay(50)
onToggleEmojiPicker(true)
}
// Показываем эмодзи сразу без задержки (эмодзи уже предзагружены)
onToggleEmojiPicker(true)
}
}
@@ -1760,7 +1867,7 @@ private fun MessageInputBar(
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.windowInsetsPadding(WindowInsets.ime.only(WindowInsetsSides.Bottom))
) {
// Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут)
if (isBlocked) {
@@ -1992,8 +2099,8 @@ private fun MessageInputBar(
// Мгновенно когда клавиатура открывается
snap()
} else {
// Плавно когда открываем/закрываем эмодзи без клавиатуры
tween(durationMillis = 200, easing = FastOutSlowInEasing)
// Быстрая анимация для мгновенного отклика (как в Telegram)
tween(durationMillis = 150, easing = TelegramEasing)
},
label = "EmojiPanelHeight"
)
@@ -2004,7 +2111,8 @@ private fun MessageInputBar(
.height(animatedHeight)
.clipToBounds()
) {
if (showEmojiPicker && !isKeyboardVisible) {
// 🚀 Рендерим панель только когда нужно
if (showEmojiPicker && !isKeyboardVisible && animatedHeight > 0.dp) {
AppleEmojiPickerPanel(
isDarkTheme = isDarkTheme,
onEmojiSelected = { emoji ->

View File

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

View File

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

View File

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