feat: Enhance packet sending logic to handle connection issues and implement swipe-to-reply functionality in chat UI
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1407,10 +1440,69 @@ private fun MessageBubble(
|
||||
|
||||
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.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
|
||||
@@ -1704,6 +1797,23 @@ private fun MessageInputBar(
|
||||
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
|
||||
val imeHeight = with(density) { imeInsets.getBottom(density).toDp() }
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user