Релиз 1.2.6: sync-статусы, emoji-подсказки и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 27m7s
Some checks failed
Android Kernel Build / build (push) Failing after 27m7s
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.2.5"
|
val rosettaVersionName = "1.2.6"
|
||||||
val rosettaVersionCode = 27 // Increment on each release
|
val rosettaVersionCode = 28 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -806,14 +806,10 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
packet.chachaKey
|
packet.chachaKey
|
||||||
}
|
}
|
||||||
|
|
||||||
val isSelfDialog = packet.toPublicKey.trim() == account
|
// Desktop parity (useSynchronize.ts):
|
||||||
// Для исходящих сообщений статус доставки меняется ТОЛЬКО по PacketDelivery.
|
// own messages received via PacketMessage sync are inserted as DELIVERED immediately.
|
||||||
val initialDeliveredStatus =
|
// WAITING is used only for messages created locally on this device before PacketDelivery.
|
||||||
if (isOwnMessage && !isSelfDialog) {
|
val initialDeliveredStatus = DeliveryStatus.DELIVERED.value
|
||||||
DeliveryStatus.WAITING.value
|
|
||||||
} else {
|
|
||||||
DeliveryStatus.DELIVERED.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем entity для кэша и возможной вставки
|
// Создаем entity для кэша и возможной вставки
|
||||||
val entity =
|
val entity =
|
||||||
@@ -1131,6 +1127,23 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
val privateKey = currentPrivateKey ?: return
|
val privateKey = currentPrivateKey ?: return
|
||||||
val now = System.currentTimeMillis()
|
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)
|
// Mark expired messages as ERROR (older than 80 seconds)
|
||||||
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||||
if (expiredCount > 0) {
|
if (expiredCount > 0) {
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Что обновлено после версии 1.2.4
|
Синхронизация статусов (desktop parity)
|
||||||
|
- Исправлено ложное «ошибка отправки» для сообщений, пришедших в sync с других устройств
|
||||||
|
- Синхронизированные исходящие теперь сразу помечаются как доставленные, как в desktop
|
||||||
|
- Добавлена авто-нормализация старых sync-сообщений со статусами WAITING/ERROR в DELIVERED
|
||||||
|
|
||||||
Статусы отправки и прочтения
|
Emoji-подсказки в поле ввода
|
||||||
- Исправлены ложные двойные галочки у фото и медиа-сообщений до фактической доставки
|
- Переработан UI подсказок: отдельный плавающий пузырёк над инпутом без изменения его высоты
|
||||||
- Исходящие сообщения теперь остаются в ожидании до реального PacketDelivery с сервера
|
- Исправлен клиппинг/обрезание пузырька внизу экрана
|
||||||
- PacketRead больше не переводит недоставленные сообщения в «прочитано»
|
- Рендер эмодзи в подсказках приведён к Apple-like отображению
|
||||||
- Синхронизация статусов между БД, кэшем и UI стала стабильнее в личных чатах и группах
|
|
||||||
|
|
||||||
Надёжность отображения
|
Полировка интерфейса
|
||||||
- Убраны ложные переходы статусов при быстрых событиях синхронизации
|
- В списке чатов выровнен текст после «Draft:» по одной line-height линии
|
||||||
- Логика read receipts приведена к более корректному серверному подтверждению
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -482,6 +482,35 @@ interface MessageDao {
|
|||||||
)
|
)
|
||||||
suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int
|
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.
|
* Update delivery status AND timestamp on delivery confirmation.
|
||||||
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
|
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
|
||||||
|
|||||||
@@ -4327,7 +4327,7 @@ fun DialogItemContent(
|
|||||||
if (showTyping) {
|
if (showTyping) {
|
||||||
TypingIndicatorSmall()
|
TypingIndicatorSmall()
|
||||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||||
Row {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "Draft: ",
|
text = "Draft: ",
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
@@ -4343,7 +4343,8 @@ fun DialogItemContent(
|
|||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
enableLinks = false
|
enableLinks = false,
|
||||||
|
minHeightMultiplier = 1f
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.ui.window.Popup
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.PopupProperties
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
@@ -135,6 +137,8 @@ fun MessageInputBar(
|
|||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
|
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
|
// Auto-focus when reply panel opens
|
||||||
LaunchedEffect(hasReply, editTextView) {
|
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
|
// Close keyboard when user is blocked
|
||||||
LaunchedEffect(isBlocked) {
|
LaunchedEffect(isBlocked) {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
@@ -357,6 +380,14 @@ fun MessageInputBar(
|
|||||||
editTextView?.requestFocus()
|
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 }) {
|
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
// BLOCKED CHAT FOOTER
|
// BLOCKED CHAT FOOTER
|
||||||
@@ -695,14 +726,101 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// INPUT ROW
|
// INPUT ROW + Telegram-like flat emoji suggestion strip overlay
|
||||||
Row(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 48.dp)
|
.graphicsLayer { clip = false }
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
) {
|
||||||
|
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(
|
IconButton(
|
||||||
onClick = onAttachClick,
|
onClick = onAttachClick,
|
||||||
modifier = Modifier.size(40.dp)
|
modifier = Modifier.size(40.dp)
|
||||||
@@ -740,6 +858,10 @@ fun MessageInputBar(
|
|||||||
if (hasFocus && showEmojiPicker) {
|
if (hasFocus && showEmojiPicker) {
|
||||||
onToggleEmojiPicker(false)
|
onToggleEmojiPicker(false)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onSelectionChanged = { start, end ->
|
||||||
|
selectionStart = start
|
||||||
|
selectionEnd = end
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -779,6 +901,7 @@ fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
) : EditText(context, attrs, defStyleAttr) {
|
) : EditText(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
var onTextChange: ((String) -> Unit)? = null
|
var onTextChange: ((String) -> Unit)? = null
|
||||||
|
var onSelectionRangeChange: ((Int, Int) -> Unit)? = null
|
||||||
/** Called when the view's measured height changes (for multiline expansion animation). */
|
/** Called when the view's measured height changes (for multiline expansion animation). */
|
||||||
var onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
var onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
||||||
private var isUpdating = false
|
private var isUpdating = false
|
||||||
@@ -173,6 +174,11 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
lastMeasuredHeight = h
|
lastMeasuredHeight = h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||||
|
super.onSelectionChanged(selStart, selEnd)
|
||||||
|
onSelectionRangeChange?.invoke(selStart, selEnd)
|
||||||
|
}
|
||||||
|
|
||||||
fun setTextWithEmojis(newText: String) {
|
fun setTextWithEmojis(newText: String) {
|
||||||
if (newText == text.toString()) return
|
if (newText == text.toString()) return
|
||||||
isUpdating = true
|
isUpdating = true
|
||||||
@@ -310,7 +316,8 @@ fun AppleEmojiTextField(
|
|||||||
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
||||||
requestFocus: Boolean = false,
|
requestFocus: Boolean = false,
|
||||||
onFocusChanged: ((Boolean) -> Unit)? = null,
|
onFocusChanged: ((Boolean) -> Unit)? = null,
|
||||||
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null,
|
||||||
|
onSelectionChanged: ((Int, Int) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
// Храним ссылку на view для управления фокусом
|
// Храним ссылку на view для управления фокусом
|
||||||
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
||||||
@@ -333,6 +340,7 @@ fun AppleEmojiTextField(
|
|||||||
setHint(hint)
|
setHint(hint)
|
||||||
setTextSize(textSize)
|
setTextSize(textSize)
|
||||||
onTextChange = onValueChange
|
onTextChange = onValueChange
|
||||||
|
onSelectionRangeChange = onSelectionChanged
|
||||||
this.onHeightChanged = onHeightChanged
|
this.onHeightChanged = onHeightChanged
|
||||||
// Убираем все возможные фоны у EditText
|
// Убираем все возможные фоны у EditText
|
||||||
background = null
|
background = null
|
||||||
@@ -351,6 +359,7 @@ fun AppleEmojiTextField(
|
|||||||
// Always update the callback to prevent stale lambda references
|
// Always update the callback to prevent stale lambda references
|
||||||
// after recomposition (e.g., after sending a photo)
|
// after recomposition (e.g., after sending a photo)
|
||||||
view.onTextChange = onValueChange
|
view.onTextChange = onValueChange
|
||||||
|
view.onSelectionRangeChange = onSelectionChanged
|
||||||
|
|
||||||
if (view.text.toString() != value) {
|
if (view.text.toString() != value) {
|
||||||
view.setTextWithEmojis(value)
|
view.setTextWithEmojis(value)
|
||||||
|
|||||||
Reference in New Issue
Block a user