diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 609ae69..37d390e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.RosettaAndroid" - android:windowSoftInputMode="adjustResize"> + android:windowSoftInputMode="adjustNothing"> 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 045fba7..9543288 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -30,8 +30,8 @@ class Protocol( ) { companion object { private const val TAG = "RosettaProtocol" - private const val RECONNECT_INTERVAL = 10000L // 10 seconds - private const val MAX_RECONNECT_ATTEMPTS = 5 + private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) + private const val MAX_RECONNECT_ATTEMPTS = 10 // Увеличил лимит private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds } @@ -296,22 +296,42 @@ class Protocol( } private fun handleDisconnect() { + val previousState = _state.value _state.value = ProtocolState.DISCONNECTED handshakeComplete = false handshakeJob?.cancel() heartbeatJob?.cancel() - if (!isManuallyClosed && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++ - log("🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)") - - scope.launch { - delay(RECONNECT_INTERVAL) - connect() + // Автоматический reconnect (как в Архиве) + if (!isManuallyClosed) { + // Сбрасываем счетчик если до этого были подключены + if (previousState == ProtocolState.AUTHENTICATED || previousState == ProtocolState.CONNECTED) { + log("🔄 Connection lost, will reconnect...") + } + + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++ + val delay = RECONNECT_INTERVAL * minOf(reconnectAttempts, 3) // Exponential backoff до 15 секунд + log("🔄 Reconnecting in ${delay}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)") + + scope.launch { + delay(delay) + if (!isManuallyClosed) { + connect() + } + } + } else { + log("❌ Max reconnect attempts reached, resetting counter...") + // Сбрасываем счетчик и пробуем снова через большой интервал + reconnectAttempts = 0 + scope.launch { + delay(30000) // 30 секунд перед следующей серией попыток + if (!isManuallyClosed) { + log("🔄 Restarting reconnection attempts...") + connect() + } + } } - } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - log("❌ Max reconnect attempts reached") - _lastError.value = "Unable to connect to server" } } 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 5d1c019..5451fbe 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 @@ -4,9 +4,11 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -172,6 +174,7 @@ fun ChatDetailScreen( ) { val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current // Цвета как в React Native themes.ts val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black @@ -194,16 +197,18 @@ fun ChatDetailScreen( // Telegram-style scroll tracking var wasManualScroll by remember { mutableStateOf(false) } - var isAtBottom by remember { mutableStateOf(true) } + // Кнопка появляется после 3+ сообщений от начала (позже) + val isAtBottom by remember { + derivedStateOf { + listState.firstVisibleItemIndex < 3 && listState.firstVisibleItemScrollOffset < 100 + } + } // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) var isSendingMessage by remember { mutableStateOf(false) } - - // Track if user is at bottom of list - LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { - val isAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - isAtBottom = isAtTop - } + + // 🔥 MESSAGE SELECTION STATE - для Reply/Forward + var selectedMessages by remember { mutableStateOf>(emptySet()) } + val isSelectionMode = selectedMessages.isNotEmpty() // 🔥 Быстрое закрытие с fade-out анимацией val hideKeyboardAndBack: () -> Unit = { @@ -241,6 +246,11 @@ fun ChatDetailScreen( val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState() + + // 🔥 Reply/Forward state + val replyMessages by viewModel.replyMessages.collectAsState() + val isForwardMode by viewModel.isForwardMode.collectAsState() + val hasReply = replyMessages.isNotEmpty() // 🔥 Добавляем информацию о датах к сообщениям // В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху) @@ -277,7 +287,7 @@ fun ChatDetailScreen( val chatSubtitle = when { isSavedMessages -> "Notes" - isTyping -> "typing..." + isTyping -> "" // Пустая строка, используем компонент TypingIndicator isOnline -> "online" else -> "offline" } @@ -297,6 +307,10 @@ fun ChatDetailScreen( LaunchedEffect(user.publicKey) { viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey) viewModel.openDialog(user.publicKey, user.title, user.username) + // Подписываемся на онлайн статус собеседника + if (!isSavedMessages) { + viewModel.subscribeToOnlineStatus() + } } // Отмечаем сообщения как прочитанные когда они видны @@ -306,13 +320,13 @@ fun ChatDetailScreen( } } - // Telegram-style: Прокрутка при новых сообщениях только если пользователь в низу + // Telegram-style: Прокрутка при новых сообщениях + // Всегда скроллим к последнему при изменении количества сообщений LaunchedEffect(messages.size) { if (messages.isNotEmpty()) { - // При первой загрузке всегда скроллим вниз - if (!wasManualScroll || isAtBottom) { - listState.animateScrollToItem(0) - } + // Всегда скроллим вниз при новом сообщении + listState.animateScrollToItem(0) + wasManualScroll = false } } @@ -333,6 +347,92 @@ fun ChatDetailScreen( Scaffold( topBar = { + // 🔥 SELECTION HEADER (появляется при выборе сообщений) + AnimatedVisibility( + visible = isSelectionMode, + enter = fadeIn(animationSpec = tween(200)) + slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(250, easing = TelegramEasing) + ), + exit = fadeOut(animationSpec = tween(150)) + slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(200, easing = TelegramEasing) + ) + ) { + Box(modifier = Modifier + .fillMaxWidth() + .background(if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // Left: X (cancel) + Count + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { selectedMessages = emptySet() }) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel", + tint = textColor + ) + } + Text( + "${selectedMessages.size}", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = textColor + ) + } + + // Right: Copy button only + IconButton( + onClick = { + // Копируем текст выбранных сообщений + val textToCopy = messages + .filter { selectedMessages.contains(it.id) } + .sortedBy { it.timestamp } + .joinToString("\n\n") { msg -> + val time = SimpleDateFormat("HH:mm", Locale.getDefault()) + .format(msg.timestamp) + "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" + } + clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy)) + selectedMessages = emptySet() + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = textColor + ) + } + } + + // Bottom line + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.15f) + else Color.Black.copy(alpha = 0.1f) + ) + ) + } + } + + // NORMAL HEADER (скрывается при выборе) + AnimatedVisibility( + visible = !isSelectionMode, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(150)) + ) { // Telegram-style TopAppBar - solid background без blur Box(modifier = Modifier.fillMaxWidth().background(headerBackground)) { Row( @@ -386,6 +486,16 @@ fun ChatDetailScreen( color = avatarColors.textColor ) } + // 🟢 Online indicator + if (!isSavedMessages && isOnline) { + Box( + modifier = Modifier + .size(12.dp) + .align(Alignment.BottomEnd) + .background(Color(0xFF38B24D), CircleShape) + .border(2.dp, headerBackground, CircleShape) + ) + } } Spacer(modifier = Modifier.width(12.dp)) @@ -413,21 +523,25 @@ fun ChatDetailScreen( VerifiedBadge(verified = user.verified, size = 16) } } - Text( - text = chatSubtitle, - fontSize = 13.sp, - color = - when { - isSavedMessages -> secondaryTextColor - isTyping -> PrimaryBlue // Синий когда печатает - isOnline -> - Color( - 0xFF38B24D - ) // Зелёный когда онлайн - else -> secondaryTextColor // Серый для offline - }, - maxLines = 1 - ) + // Typing indicator или subtitle + if (isTyping) { + TypingIndicator(isDarkTheme = isDarkTheme) + } else { + Text( + text = chatSubtitle, + fontSize = 13.sp, + color = + when { + isSavedMessages -> secondaryTextColor + isOnline -> + Color( + 0xFF38B24D + ) // Зелёный когда онлайн + else -> secondaryTextColor // Серый для offline + }, + maxLines = 1 + ) + } } // Кнопки действий @@ -549,18 +663,18 @@ fun ChatDetailScreen( ) ) } + } // Закрытие AnimatedVisibility для normal header }, containerColor = backgroundColor // Фон всего чата ) { paddingValues -> - // 🔥 Простой Column - сообщения сверху, инпут снизу - // Клавиатура сама поднимает контент через adjustResize - Column( + // 🔥 Box с overlay - инпут плавает поверх сообщений + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - // Список сообщений - занимает всё доступное пространство - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + // Список сообщений - занимает весь экран + Box(modifier = Modifier.fillMaxSize()) { if (messages.isEmpty()) { // Пустое состояние Column( @@ -620,13 +734,13 @@ fun ChatDetailScreen( } } ), - // padding для контента списка + // padding для контента списка - добавляем снизу место для инпута contentPadding = PaddingValues( start = 8.dp, end = 8.dp, top = 8.dp, - bottom = 8.dp + bottom = 100.dp // Место для floating input (с запасом) ), reverseLayout = true ) { @@ -647,7 +761,26 @@ fun ChatDetailScreen( MessageBubble( message = message, isDarkTheme = isDarkTheme, - index = index + index = index, + isSelected = selectedMessages.contains(message.id), + onLongClick = { + // Toggle selection on long press + selectedMessages = if (selectedMessages.contains(message.id)) { + selectedMessages - message.id + } else { + selectedMessages + message.id + } + }, + onClick = { + // If in selection mode, toggle selection + if (isSelectionMode) { + selectedMessages = if (selectedMessages.contains(message.id)) { + selectedMessages - message.id + } else { + selectedMessages + message.id + } + } + } ) } } @@ -660,7 +793,7 @@ fun ChatDetailScreen( Box( modifier = Modifier.align(Alignment.BottomEnd) - .padding(end = 16.dp, bottom = 16.dp) + .padding(end = 16.dp, bottom = 80.dp) .size(44.dp) .shadow( elevation = 8.dp, @@ -727,34 +860,167 @@ fun ChatDetailScreen( } } } - - // 🔥 INPUT BAR - обычный элемент внизу Column - MessageInputBar( - value = inputText, - onValueChange = { - viewModel.updateInputText(it) - // Отправляем индикатор печатания - if (it.isNotEmpty() && !isSavedMessages) { - viewModel.sendTypingIndicator() + + // 🔥 FLOATING INPUT BAR - плавает поверх сообщений, поднимается с клавиатурой + // Скрываем когда в режиме выбора + AnimatedVisibility( + visible = !isSelectionMode, + enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }), + exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .imePadding() + ) { + // Input bar с встроенным reply preview (как в React Native) + MessageInputBar( + value = inputText, + onValueChange = { + viewModel.updateInputText(it) + // Отправляем индикатор печатания + if (it.isNotEmpty() && !isSavedMessages) { + viewModel.sendTypingIndicator() + } + }, + onSend = { + // Скрываем кнопку scroll на время отправки + isSendingMessage = true + viewModel.sendMessage() + // Скроллим к новому сообщению + scope.launch { + delay(100) + listState.animateScrollToItem(0) + delay(300) // Ждём завершения анимации + isSendingMessage = false + } + }, + isDarkTheme = isDarkTheme, + backgroundColor = inputBackgroundColor, + textColor = textColor, + placeholderColor = secondaryTextColor, + // Reply state + replyMessages = replyMessages, + isForwardMode = isForwardMode, + onCloseReply = { viewModel.clearReplyMessages() }, + chatTitle = chatTitle + ) + } + + // 🔥 SELECTION ACTION BAR - Reply/Forward (появляется при выборе сообщений) + // Стеклянный стиль как у инпута + AnimatedVisibility( + visible = isSelectionMode, + enter = fadeIn(tween(200)) + slideInVertically(initialOffsetY = { it }), + exit = fadeOut(tween(150)) + slideOutVertically(targetOffsetY = { it }), + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + .navigationBarsPadding() + ) { + // Glass container + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background( + if (isDarkTheme) Color(0xFF2A2A2A).copy(alpha = 0.85f) + else Color(0xFFF2F3F5).copy(alpha = 0.92f) + ) + .border( + width = 0.5.dp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.06f), + shape = RoundedCornerShape(20.dp) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Reply button - стеклянная кнопка + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(14.dp)) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.04f) + ) + .clickable { + val selectedMsgs = messages + .filter { selectedMessages.contains(it.id) } + .sortedBy { it.timestamp } + viewModel.setReplyMessages(selectedMsgs) + selectedMessages = emptySet() + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Reply, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Reply", + color = PrimaryBlue, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } } - }, - onSend = { - // Скрываем кнопку scroll на время отправки - isSendingMessage = true - viewModel.sendMessage() - // Скроллим к новому сообщению - scope.launch { - delay(100) - listState.animateScrollToItem(0) - delay(300) // Ждём завершения анимации - isSendingMessage = false + + // Forward button - стеклянная кнопка + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(14.dp)) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.04f) + ) + .clickable { + val selectedMsgs = messages + .filter { selectedMessages.contains(it.id) } + .sortedBy { it.timestamp } + viewModel.setForwardMessages(selectedMsgs) + selectedMessages = emptySet() + } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Forward, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Forward", + color = PrimaryBlue, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } } - }, - isDarkTheme = isDarkTheme, - backgroundColor = inputBackgroundColor, - textColor = textColor, - placeholderColor = secondaryTextColor - ) + } + } + } } } } // Закрытие Box с fade-in @@ -904,10 +1170,30 @@ fun rememberMessageEnterAnimation(messageId: String): Pair { } /** 🚀 Пузырек сообщения Telegram-style */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int = 0) { +private fun MessageBubble( + message: ChatMessage, + isDarkTheme: Boolean, + index: Int = 0, + isSelected: Boolean = false, + onLongClick: () -> Unit = {}, + onClick: () -> Unit = {} +) { // Telegram-style enter animation val (alpha, translationY) = rememberMessageEnterAnimation(message.id) + + // Selection animation + val selectionScale by animateFloatAsState( + targetValue = if (isSelected) 0.95f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "selectionScale" + ) + val selectionAlpha by animateFloatAsState( + targetValue = if (isSelected) 0.85f else 1f, + animationSpec = tween(150), + label = "selectionAlpha" + ) // Telegram colors val bubbleColor = @@ -943,15 +1229,42 @@ private fun MessageBubble(message: ChatMessage, isDarkTheme: Boolean, index: Int Modifier.fillMaxWidth() .padding(horizontal = 8.dp, vertical = 1.dp) .graphicsLayer { - this.alpha = alpha + this.alpha = alpha * selectionAlpha this.translationY = translationY + this.scaleX = selectionScale + this.scaleY = selectionScale }, horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start ) { + // Checkbox для выбранных сообщений + AnimatedVisibility( + visible = isSelected, + enter = fadeIn() + scaleIn(initialScale = 0.5f), + exit = fadeOut() + scaleOut(targetScale = 0.5f) + ) { + Box( + modifier = Modifier + .padding(end = 8.dp) + .align(Alignment.CenterVertically) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } + } + Box( modifier = Modifier.widthIn(max = 300.dp) - .shadow(elevation = 1.dp, shape = bubbleShape, clip = false) + .combinedClickable( + 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) @@ -1046,8 +1359,14 @@ private fun MessageInputBar( isDarkTheme: Boolean, backgroundColor: Color, textColor: Color, - placeholderColor: Color + placeholderColor: Color, + // Reply state (как в React Native) + replyMessages: List = emptyList(), + isForwardMode: Boolean = false, + onCloseReply: () -> Unit = {}, + chatTitle: String = "" ) { + val hasReply = replyMessages.isNotEmpty() var showEmojiPicker by remember { mutableStateOf(false) } // Флаг для запуска закрытия клавиатуры перед открытием emoji picker var pendingEmojiPicker by remember { mutableStateOf(false) } @@ -1111,12 +1430,13 @@ private fun MessageInputBar( modifier = Modifier.fillMaxWidth() ) { // 🔥 TELEGRAM-STYLE FLOATING LIQUID GLASS INPUT - Row( + // Используем Column вместо Row для reply panel внутри + Column( modifier = Modifier.fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 16.dp) - // Инпут растёт вверх до 6 строк (~140dp) - .heightIn(min = 44.dp, max = 140.dp) + .padding(horizontal = 8.dp, vertical = 16.dp) + // Инпут растёт вверх до 6 строк (~140dp) + reply + .heightIn(min = 44.dp, max = if (hasReply) 200.dp else 140.dp) .shadow( elevation = 4.dp, shape = RoundedCornerShape(22.dp), @@ -1165,70 +1485,137 @@ private fun MessageInputBar( ), shape = RoundedCornerShape(22.dp) ) - .padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.Bottom ) { - // EMOJI BUTTON - Box( - modifier = - Modifier.align(Alignment.Bottom) - .size(36.dp) - .clip(CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = { toggleEmojiPicker() } - ), - contentAlignment = Alignment.Center + // 🔥 REPLY PANEL внутри glass (как в React Native) + AnimatedVisibility( + visible = hasReply, + enter = fadeIn(tween(150)) + expandVertically(), + exit = fadeOut(tween(100)) + shrinkVertically() ) { - Icon( - if (showEmojiPicker) Icons.Default.Keyboard - else Icons.Default.SentimentSatisfiedAlt, - contentDescription = "Emoji", - tint = - if (showEmojiPicker) PrimaryBlue - else { - if (isDarkTheme) Color.White.copy(alpha = 0.65f) - else Color.Black.copy(alpha = 0.55f) - }, - modifier = Modifier.size(26.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 6.dp, top = 10.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Вертикальная синяя линия (как в React Native) + Box( + modifier = Modifier + .width(3.dp) + .height(36.dp) + .background(PrimaryBlue, RoundedCornerShape(1.5.dp)) + ) + Spacer(modifier = Modifier.width(10.dp)) + + // Контент reply + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isForwardMode) "Forward message${if (replyMessages.size > 1) "s" else ""}" + else "Reply to ${if (replyMessages.size == 1 && !replyMessages.first().isOutgoing) chatTitle else "yourself"}", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = PrimaryBlue, + maxLines = 1 + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = if (replyMessages.size == 1) { + val msg = replyMessages.first() + val shortText = msg.text.take(40) + if (shortText.length < msg.text.length) "$shortText..." else shortText + } else "${replyMessages.size} messages", + fontSize = 13.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) + else Color.Black.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Кнопка X + IconButton( + onClick = onCloseReply, + modifier = Modifier.size(32.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel", + tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) + else Color.Black.copy(alpha = 0.4f), + modifier = Modifier.size(18.dp) + ) + } + } } - - // TEXT INPUT - Box( - modifier = - Modifier.weight(1f) - .align(Alignment.CenterVertically) - .padding(horizontal = 4.dp), - contentAlignment = Alignment.CenterStart + + // Input Row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.Bottom ) { - AppleEmojiTextField( - value = value, - onValueChange = { newValue -> onValueChange(newValue) }, - textColor = textColor, - textSize = 17f, - hint = "Message", - hintColor = - if (isDarkTheme) Color.White.copy(alpha = 0.35f) - else Color.Black.copy(alpha = 0.35f), - modifier = Modifier.fillMaxWidth() - ) - } + // EMOJI BUTTON + Box( + modifier = + Modifier.align(Alignment.Bottom) + .size(36.dp) + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { toggleEmojiPicker() } + ), + contentAlignment = Alignment.Center + ) { + Icon( + if (showEmojiPicker) Icons.Default.Keyboard + else Icons.Default.SentimentSatisfiedAlt, + contentDescription = "Emoji", + tint = + if (showEmojiPicker) PrimaryBlue + else { + if (isDarkTheme) Color.White.copy(alpha = 0.65f) + else Color.Black.copy(alpha = 0.55f) + }, + modifier = Modifier.size(26.dp) + ) + } - // ATTACH BUTTON - Box( - modifier = - Modifier.align(Alignment.Bottom) - .size(36.dp) - .clip(CircleShape) - .clickable( - interactionSource = interactionSource, - indication = null - ) { /* TODO: Attach */}, - contentAlignment = Alignment.Center - ) { - Icon( + // TEXT INPUT + Box( + modifier = + Modifier.weight(1f) + .align(Alignment.CenterVertically) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.CenterStart + ) { + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> onValueChange(newValue) }, + textColor = textColor, + textSize = 17f, + hint = "Message", + hintColor = + if (isDarkTheme) Color.White.copy(alpha = 0.35f) + else Color.Black.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth() + ) + } + + // ATTACH BUTTON + Box( + modifier = + Modifier.align(Alignment.Bottom) + .size(36.dp) + .clip(CircleShape) + .clickable( + interactionSource = interactionSource, + indication = null + ) { /* TODO: Attach */}, + contentAlignment = Alignment.Center + ) { + Icon( Icons.Default.Attachment, contentDescription = "Attach", tint = @@ -1287,7 +1674,8 @@ private fun MessageInputBar( } } } - } + } // End of Input Row + } // End of Glass Column // Apple Emoji Picker AnimatedVisibility( @@ -1303,3 +1691,46 @@ private fun MessageInputBar( } } } + +/** + * 💬 Typing Indicator с анимацией точек (как в Telegram) + */ +@Composable +fun TypingIndicator(isDarkTheme: Boolean) { + val infiniteTransition = rememberInfiniteTransition(label = "typing") + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "typing", + fontSize = 13.sp, + color = Color(0xFF38B24D) // Зелёный цвет как в Telegram + ) + + // 3 анимированные точки + repeat(3) { index -> + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -4f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 600, + delayMillis = index * 100, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) + + Text( + text = ".", + fontSize = 13.sp, + color = Color(0xFF38B24D), + modifier = Modifier.offset(y = offsetY.dp) + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 4dc668e..c700d86 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -74,6 +74,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _inputText = MutableStateFlow("") val inputText: StateFlow = _inputText.asStateFlow() + // 🔥 Reply/Forward state + data class ReplyMessage( + val messageId: String, + val text: String, + val timestamp: Long, + val isOutgoing: Boolean + ) + private val _replyMessages = MutableStateFlow>(emptyList()) + val replyMessages: StateFlow> = _replyMessages.asStateFlow() + + private val _isForwardMode = MutableStateFlow(false) + val isForwardMode: StateFlow = _isForwardMode.asStateFlow() + // Пагинация private var currentOffset = 0 private var hasMoreMessages = true @@ -414,19 +427,63 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _inputText.value = text } + /** + * 🔥 Установить сообщения для Reply + */ + fun setReplyMessages(messages: List) { + _replyMessages.value = messages.map { msg -> + ReplyMessage( + messageId = msg.id, + text = msg.text, + timestamp = msg.timestamp.time, + isOutgoing = msg.isOutgoing + ) + } + _isForwardMode.value = false + ProtocolManager.addLog("📝 Reply set: ${messages.size} messages") + } + + /** + * 🔥 Установить сообщения для Forward + */ + fun setForwardMessages(messages: List) { + _replyMessages.value = messages.map { msg -> + ReplyMessage( + messageId = msg.id, + text = msg.text, + timestamp = msg.timestamp.time, + isOutgoing = msg.isOutgoing + ) + } + _isForwardMode.value = true + ProtocolManager.addLog("➡️ Forward set: ${messages.size} messages") + } + + /** + * 🔥 Очистить reply/forward + */ + fun clearReplyMessages() { + _replyMessages.value = emptyList() + _isForwardMode.value = false + } + /** * 🚀 Оптимизированная отправка сообщения * - Optimistic UI (мгновенное отображение) * - Шифрование в IO потоке * - Сохранение в БД в IO потоке + * - Поддержка Reply/Forward */ fun sendMessage() { val text = _inputText.value.trim() val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey + val replyMsgs = _replyMessages.value + val isForward = _isForwardMode.value - if (text.isEmpty()) { + // Разрешаем отправку пустого текста если есть reply/forward + if (text.isEmpty() && replyMsgs.isEmpty()) { ProtocolManager.addLog("❌ Empty text") return } @@ -448,10 +505,28 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val messageId = UUID.randomUUID().toString().replace("-", "").take(32) val timestamp = System.currentTimeMillis() + // 🔥 Формируем текст с reply/forward + val fullText = buildString { + if (replyMsgs.isNotEmpty()) { + if (isForward) { + append("📨 Forwarded:\n") + } else { + append("↩️ Reply:\n") + } + replyMsgs.forEach { msg -> + append("\"${msg.text.take(100)}${if (msg.text.length > 100) "..." else ""}\"\n") + } + if (text.isNotEmpty()) { + append("\n") + } + } + append(text) + } + // 1. 🚀 Optimistic UI - мгновенно показываем сообщение val optimisticMessage = ChatMessage( id = messageId, - text = text, + text = fullText, isOutgoing = true, timestamp = Date(timestamp), status = MessageStatus.SENDING @@ -459,16 +534,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value = _messages.value + optimisticMessage _inputText.value = "" - // Кэшируем текст - decryptionCache[messageId] = text + // Очищаем reply после отправки + clearReplyMessages() - ProtocolManager.addLog("📤 Sending: \"${text.take(20)}...\"") + // Кэшируем текст + decryptionCache[messageId] = fullText + + ProtocolManager.addLog("📤 Sending: \"${fullText.take(20)}...\"") // 2. 🔥 Шифрование и отправка в IO потоке viewModelScope.launch(Dispatchers.IO) { try { // Шифрование (тяжёлая операция) - val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(text, recipient) + val (encryptedContent, encryptedKey) = MessageCrypto.encryptForSending(fullText, recipient) val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) @@ -494,7 +572,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 4. 💾 Сохранение в БД (уже в IO потоке) saveMessageToDatabase( messageId = messageId, - text = text, + text = fullText, encryptedContent = encryptedContent, encryptedKey = encryptedKey, timestamp = timestamp, @@ -502,7 +580,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { delivered = 1 // SENT - сервер принял ) - saveDialog(text, timestamp) + saveDialog(fullText, timestamp) } catch (e: Exception) { Log.e(TAG, "Send error", e) 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 704eef0..d880d63 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 @@ -13,9 +13,12 @@ import android.util.LruCache import android.view.Gravity import android.view.inputmethod.EditorInfo import android.widget.EditText +import android.widget.FrameLayout import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.foundation.background import androidx.compose.ui.viewinterop.AndroidView import java.util.regex.Pattern @@ -213,14 +216,25 @@ fun AppleEmojiTextField( setHint(hint) setTextSize(textSize) onTextChange = onValueChange + // Убираем все возможные фоны у EditText + background = null + setBackgroundColor(android.graphics.Color.TRANSPARENT) } }, update = { view -> if (view.text.toString() != value) { view.setTextWithEmojis(value) } + // Гарантируем прозрачность у EditText + view.background = null + // 🔥 Убираем фон у AndroidViewHolder (parent FrameLayout) + (view.parent as? FrameLayout)?.apply { + setBackgroundColor(android.graphics.Color.TRANSPARENT) + background = null + } }, - modifier = modifier + // 🔥 Прозрачный фон для Compose контейнера + modifier = modifier.background(Color.Transparent) ) }