Релиз 1.2.6: sync-статусы, emoji-подсказки и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 27m7s

This commit is contained in:
2026-03-20 21:56:52 +05:00
parent b2558653b7
commit c929685e04
8 changed files with 356 additions and 27 deletions

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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 =

View File

@@ -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<String>
/**
* Update delivery status AND timestamp on delivery confirmation.
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.

View File

@@ -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 {

View File

@@ -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<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(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,7 +726,94 @@ fun MessageInputBar(
}
}
// INPUT ROW
// INPUT ROW + Telegram-like flat emoji suggestion strip overlay
Box(
modifier = Modifier
.fillMaxWidth()
.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()
@@ -740,6 +858,10 @@ fun MessageInputBar(
if (hasFocus && showEmojiPicker) {
onToggleEmojiPicker(false)
}
},
onSelectionChanged = { start, end ->
selectionStart = start
selectionEnd = end
}
)
}
@@ -783,6 +905,7 @@ fun MessageInputBar(
}
}
}
}
if (showForwardCancelDialog) {
val titleText =

View File

@@ -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<String, List<String>> =
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<String> {
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<String>()
(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)
}
}

View File

@@ -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<AppleEmojiEditTextView?>(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)