From c929685e04a2df87f48e61b8e614894c9c6d8702 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 20 Mar 2026 21:56:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.2.6:=20sync-?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B,=20emoji-=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=B8=20UI-?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../messenger/data/MessageRepository.kt | 29 +++- .../rosetta/messenger/data/ReleaseNotes.kt | 19 +-- .../messenger/database/MessageEntities.kt | 29 ++++ .../messenger/ui/chats/ChatsListScreen.kt | 5 +- .../ui/chats/input/ChatDetailInput.kt | 133 ++++++++++++++- .../ui/chats/input/EmojiKeywordSuggester.kt | 153 ++++++++++++++++++ .../ui/components/AppleEmojiEditText.kt | 11 +- 8 files changed, 356 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/input/EmojiKeywordSuggester.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1873062..6cd687d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.2.5" -val rosettaVersionCode = 27 // Increment on each release +val rosettaVersionName = "1.2.6" +val rosettaVersionCode = 28 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 8645c66..2ff320c 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -806,14 +806,10 @@ class MessageRepository private constructor(private val context: Context) { packet.chachaKey } - val isSelfDialog = packet.toPublicKey.trim() == account - // Для исходящих сообщений статус доставки меняется ТОЛЬКО по PacketDelivery. - val initialDeliveredStatus = - if (isOwnMessage && !isSelfDialog) { - DeliveryStatus.WAITING.value - } else { - DeliveryStatus.DELIVERED.value - } + // Desktop parity (useSynchronize.ts): + // own messages received via PacketMessage sync are inserted as DELIVERED immediately. + // WAITING is used only for messages created locally on this device before PacketDelivery. + val initialDeliveredStatus = DeliveryStatus.DELIVERED.value // Создаем entity для кэша и возможной вставки val entity = @@ -1131,6 +1127,23 @@ class MessageRepository private constructor(private val context: Context) { val privateKey = currentPrivateKey ?: return val now = System.currentTimeMillis() + // Desktop parity recovery: + // historically, own synced direct messages ("sync:*" chacha_key) could be saved as WAITING/ERROR + // on Android and then incorrectly shown with failed status. + // Desktop stores them as DELIVERED from the beginning. + val syncedOpponentsWithWrongStatus = + messageDao.getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account) + val normalizedSyncedCount = messageDao.markSyncedOwnMessagesAsDelivered(account) + if (normalizedSyncedCount > 0) { + syncedOpponentsWithWrongStatus.forEach { opponentKey -> + runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) } + } + android.util.Log.i( + "MessageRepository", + "✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED" + ) + } + // Mark expired messages as ERROR (older than 80 seconds) val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) if (expiredCount > 0) { diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index fe803d9..b9d67d6 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,17 +17,18 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Что обновлено после версии 1.2.4 + Синхронизация статусов (desktop parity) + - Исправлено ложное «ошибка отправки» для сообщений, пришедших в sync с других устройств + - Синхронизированные исходящие теперь сразу помечаются как доставленные, как в desktop + - Добавлена авто-нормализация старых sync-сообщений со статусами WAITING/ERROR в DELIVERED - Статусы отправки и прочтения - - Исправлены ложные двойные галочки у фото и медиа-сообщений до фактической доставки - - Исходящие сообщения теперь остаются в ожидании до реального PacketDelivery с сервера - - PacketRead больше не переводит недоставленные сообщения в «прочитано» - - Синхронизация статусов между БД, кэшем и UI стала стабильнее в личных чатах и группах + Emoji-подсказки в поле ввода + - Переработан UI подсказок: отдельный плавающий пузырёк над инпутом без изменения его высоты + - Исправлен клиппинг/обрезание пузырька внизу экрана + - Рендер эмодзи в подсказках приведён к Apple-like отображению - Надёжность отображения - - Убраны ложные переходы статусов при быстрых событиях синхронизации - - Логика read receipts приведена к более корректному серверному подтверждению + Полировка интерфейса + - В списке чатов выровнен текст после «Draft:» по одной line-height линии """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index fa3a803..c5b9579 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -482,6 +482,35 @@ interface MessageDao { ) suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int + /** + * Desktop parity recovery: + * own direct messages synced from another device are stored with chacha_key "sync:*" + * and should always be DELIVERED (never WAITING/ERROR). + */ + @Query( + """ + UPDATE messages + SET delivered = 1 + WHERE account = :account + AND from_me = 1 + AND delivered != 1 + AND chacha_key LIKE 'sync:%' + """ + ) + suspend fun markSyncedOwnMessagesAsDelivered(account: String): Int + + @Query( + """ + SELECT DISTINCT to_public_key + FROM messages + WHERE account = :account + AND from_me = 1 + AND delivered != 1 + AND chacha_key LIKE 'sync:%' + """ + ) + suspend fun getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account: String): List + /** * Update delivery status AND timestamp on delivery confirmation. * Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery. 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 db3523f..81f7d40 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 @@ -4327,7 +4327,7 @@ fun DialogItemContent( if (showTyping) { TypingIndicatorSmall() } else if (!dialog.draftText.isNullOrEmpty()) { - Row { + Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "Draft: ", fontSize = 14.sp, @@ -4343,7 +4343,8 @@ fun DialogItemContent( fontWeight = FontWeight.Normal, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false + enableLinks = false, + minHeightMultiplier = 1f ) } } else { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index b7686aa..c673a68 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -36,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition @@ -135,6 +137,8 @@ fun MessageInputBar( val density = LocalDensity.current var editTextView by remember { mutableStateOf(null) } + var selectionStart by remember { mutableIntStateOf(value.length) } + var selectionEnd by remember { mutableIntStateOf(value.length) } // Auto-focus when reply panel opens LaunchedEffect(hasReply, editTextView) { @@ -269,6 +273,25 @@ fun MessageInputBar( } } + val emojiWordMatch = + remember(value, selectionStart, selectionEnd) { + EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd) + } + + val emojiSuggestions = + remember(emojiWordMatch) { + emojiWordMatch?.let { EmojiKeywordSuggester.suggest(it) }.orEmpty() + } + + val shouldShowEmojiSuggestions = + remember(emojiSuggestions, mentionSuggestions, isGroupChat, suppressKeyboard, showEmojiPicker) { + emojiSuggestions.isNotEmpty() && + mentionSuggestions.isEmpty() && + !(isGroupChat && shouldShowMentionSuggestions) && + !showEmojiPicker && + !suppressKeyboard + } + // Close keyboard when user is blocked LaunchedEffect(isBlocked) { if (isBlocked) { @@ -357,6 +380,14 @@ fun MessageInputBar( editTextView?.requestFocus() } + fun onSelectEmojiSuggestion(emoji: String) { + val updated = EmojiKeywordSuggester.applySuggestion(value, selectionStart, selectionEnd, emoji) + if (updated != value) { + onValueChange(updated) + } + editTextView?.requestFocus() + } + Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { if (isBlocked) { // BLOCKED CHAT FOOTER @@ -695,14 +726,101 @@ fun MessageInputBar( } } - // INPUT ROW - Row( + // INPUT ROW + Telegram-like flat emoji suggestion strip overlay + Box( modifier = Modifier .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom + .graphicsLayer { clip = false } ) { + androidx.compose.animation.AnimatedVisibility( + visible = shouldShowEmojiSuggestions, + enter = fadeIn(animationSpec = tween(120)) + + slideInVertically(animationSpec = tween(120), initialOffsetY = { it / 2 }), + exit = fadeOut(animationSpec = tween(90)) + + slideOutVertically(animationSpec = tween(90), targetOffsetY = { it / 3 }), + modifier = Modifier + .align(Alignment.TopStart) + .offset(y = (-46).dp) + .fillMaxWidth() + .padding(horizontal = 12.dp) + .zIndex(4f) + ) { + val barBackground = if (isDarkTheme) Color(0xFF303A48).copy(alpha = 0.97f) else Color(0xFFE9EEF6) + val barBorder = if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.1f) + val leadingIconBg = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.06f) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = barBackground, + shape = RoundedCornerShape(16.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, barBorder), + shadowElevation = if (isDarkTheme) 6.dp else 3.dp, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(26.dp) + .clip(CircleShape) + .background(leadingIconBg), + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Smile, + contentDescription = null, + tint = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF31415A), + modifier = Modifier.size(15.dp) + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalAlignment = Alignment.CenterVertically + ) { + items(emojiSuggestions, key = { it }) { emoji -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onSelectEmojiSuggestion(emoji) } + .padding(horizontal = 1.dp), + contentAlignment = Alignment.Center + ) { + AppleEmojiText( + text = emoji, + fontSize = 26.sp, + color = if (isDarkTheme) Color.White else Color.Black, + maxLines = 1, + enableLinks = false, + minHeightMultiplier = 1f, + onClick = { onSelectEmojiSuggestion(emoji) } + ) + } + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { IconButton( onClick = onAttachClick, modifier = Modifier.size(40.dp) @@ -740,6 +858,10 @@ fun MessageInputBar( if (hasFocus && showEmojiPicker) { onToggleEmojiPicker(false) } + }, + onSelectionChanged = { start, end -> + selectionStart = start + selectionEnd = end } ) } @@ -779,6 +901,7 @@ fun MessageInputBar( ) } } + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/EmojiKeywordSuggester.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/EmojiKeywordSuggester.kt new file mode 100644 index 0000000..2aac66f --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/EmojiKeywordSuggester.kt @@ -0,0 +1,153 @@ +package com.rosetta.messenger.ui.chats.input + +import java.util.Locale + +data class EmojiWordMatch( + val normalizedWord: String, + val start: Int, + val end: Int +) + +object EmojiKeywordSuggester { + + private const val MIN_WORD_LENGTH = 2 + private const val MAX_WORD_LENGTH = 32 + +// test + + private val keywordToEmoji: Map> = + linkedMapOf( + "спасибо" to listOf("🙏", "😊", "❤️", "🤝"), + "спс" to listOf("🙏", "😊"), + "благодарю" to listOf("🙏", "🤝"), + "пожалуйста" to listOf("🙏", "🙂"), + "привет" to listOf("👋", "🙂", "😊"), + "здравствуйте" to listOf("👋", "🙂"), + "пока" to listOf("👋", "🫡"), + "доброеутро" to listOf("☀️", "🌤️", "🙂"), + "добрыйдень" to listOf("☀️", "🙂"), + "добрыйвечер" to listOf("🌇", "🙂"), + "ок" to listOf("👌", "👍"), + "хорошо" to listOf("👌", "👍", "✅"), + "круто" to listOf("🔥", "😎", "🤘"), + "класс" to listOf("🔥", "😎", "✨"), + "да" to listOf("✅", "👍"), + "нет" to listOf("❌", "🙅"), + "люблю" to listOf("❤️", "🥰", "😘"), + "любовь" to listOf("❤️", "😍", "🥰"), + "скучаю" to listOf("🥺", "❤️"), + "смешно" to listOf("😂", "🤣", "😄"), + "лол" to listOf("😂", "🤣"), + "печаль" to listOf("😢", "🥺", "💔"), + "грустно" to listOf("😢", "🥺"), + "злюсь" to listOf("😡", "🤬"), + "злой" to listOf("😡", "🤬"), + "sorry" to listOf("🙏", "😔"), + "thanks" to listOf("🙏", "😊", "❤️"), + "thankyou" to listOf("🙏", "😊"), + "hello" to listOf("👋", "🙂", "😊"), + "hi" to listOf("👋", "🙂"), + "bye" to listOf("👋", "🫡"), + "goodmorning" to listOf("☀️", "🌤️", "🙂"), + "goodevening" to listOf("🌇", "🙂"), + "okey" to listOf("👌", "👍"), + "okay" to listOf("👌", "👍"), + "yes" to listOf("✅", "👍"), + "no" to listOf("❌", "🙅"), + "love" to listOf("❤️", "🥰", "😍"), + "missyou" to listOf("🥺", "❤️"), + "funny" to listOf("😂", "🤣", "😄"), + "sad" to listOf("😢", "🥺", "💔"), + "angry" to listOf("😡", "🤬"), + "spasibo" to listOf("🙏", "😊", "❤️"), + "privet" to listOf("👋", "🙂", "😊"), + "poka" to listOf("👋", "🫡"), + "lyublyu" to listOf("❤️", "🥰", "😘") + ) + + fun findWordAtCursor( + text: String, + selectionStart: Int, + selectionEnd: Int + ): EmojiWordMatch? { + if (selectionStart != selectionEnd) return null + if (text.isEmpty()) return null + + val cursor = selectionEnd.coerceIn(0, text.length) + var wordStart = cursor + var wordEnd = cursor + + while (wordStart > 0 && isWordChar(text[wordStart - 1])) { + wordStart-- + } + while (wordEnd < text.length && isWordChar(text[wordEnd])) { + wordEnd++ + } + + if (wordStart == wordEnd) return null + + val rawWord = text.substring(wordStart, wordEnd) + val normalized = normalizeWord(rawWord) + if (normalized.length !in MIN_WORD_LENGTH..MAX_WORD_LENGTH) return null + + return EmojiWordMatch( + normalizedWord = normalized, + start = wordStart, + end = wordEnd + ) + } + + fun suggest(match: EmojiWordMatch, maxCount: Int = 8): List { + if (maxCount <= 0) return emptyList() + + val exact = keywordToEmoji[match.normalizedWord].orEmpty() + val prefix = + keywordToEmoji + .asSequence() + .filter { (keyword, _) -> + keyword != match.normalizedWord && keyword.startsWith(match.normalizedWord) + } + .sortedBy { (keyword, _) -> keyword.length } + .flatMap { (_, emoji) -> emoji.asSequence() } + .toList() + + return buildList { + val dedupe = LinkedHashSet() + (exact + prefix).forEach { emoji -> + if (dedupe.add(emoji)) { + add(emoji) + if (size >= maxCount) return@buildList + } + } + } + } + + fun applySuggestion( + text: String, + selectionStart: Int, + selectionEnd: Int, + emoji: String + ): String { + val match = findWordAtCursor(text, selectionStart, selectionEnd) ?: return text + val prefix = text.substring(0, match.start) + val suffix = text.substring(match.end) + + val shouldAddSpace = + suffix.isEmpty() || (!suffix.first().isWhitespace() && suffix.first() !in setOf(',', '.', '!', '?', ':', ';')) + val trailing = if (shouldAddSpace) " " else "" + + return prefix + emoji + trailing + suffix + } + + private fun isWordChar(char: Char): Boolean { + return char.isLetterOrDigit() || char == '_' + } + + private fun normalizeWord(word: String): String { + return buildString(word.length) { + word.forEach { char -> + if (isWordChar(char)) append(char.lowercaseChar()) + } + }.lowercase(Locale.ROOT) + } +} 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 f555119..43e286c 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 @@ -83,6 +83,7 @@ class AppleEmojiEditTextView @JvmOverloads constructor( ) : EditText(context, attrs, defStyleAttr) { var onTextChange: ((String) -> Unit)? = null + var onSelectionRangeChange: ((Int, Int) -> Unit)? = null /** Called when the view's measured height changes (for multiline expansion animation). */ var onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null private var isUpdating = false @@ -173,6 +174,11 @@ class AppleEmojiEditTextView @JvmOverloads constructor( lastMeasuredHeight = h } + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + onSelectionRangeChange?.invoke(selStart, selEnd) + } + fun setTextWithEmojis(newText: String) { if (newText == text.toString()) return isUpdating = true @@ -310,7 +316,8 @@ fun AppleEmojiTextField( onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null, requestFocus: Boolean = false, onFocusChanged: ((Boolean) -> Unit)? = null, - onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null + onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null, + onSelectionChanged: ((Int, Int) -> Unit)? = null ) { // Храним ссылку на view для управления фокусом var editTextView by remember { mutableStateOf(null) } @@ -333,6 +340,7 @@ fun AppleEmojiTextField( setHint(hint) setTextSize(textSize) onTextChange = onValueChange + onSelectionRangeChange = onSelectionChanged this.onHeightChanged = onHeightChanged // Убираем все возможные фоны у EditText background = null @@ -351,6 +359,7 @@ fun AppleEmojiTextField( // Always update the callback to prevent stale lambda references // after recomposition (e.g., after sending a photo) view.onTextChange = onValueChange + view.onSelectionRangeChange = onSelectionChanged if (view.text.toString() != value) { view.setTextWithEmojis(value)