From 5bb95603539990db1caa132f6f591ff8844276fb Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 13 Jan 2026 06:32:16 +0500 Subject: [PATCH] feat: Enhance packet sending logic to handle connection issues and implement swipe-to-reply functionality in chat UI --- .../com/rosetta/messenger/network/Protocol.kt | 23 ++- .../messenger/ui/chats/ChatDetailScreen.kt | 144 +++++++++++++++--- .../messenger/ui/chats/ChatsListScreen.kt | 5 +- .../ui/components/AppleEmojiEditText.kt | 5 +- .../ui/components/AppleEmojiPicker.kt | 39 +++-- 5 files changed, 174 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index cdc87aa..b9a49ce 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 3a32a08..9748ec1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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(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 -> diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 5cf37b1..44794be 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 1b2ba94..17d49ae 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -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 -> diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt index 2966612..3084411 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiPicker.kt @@ -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,