feat: Enhance performance and usability in chat components and emoji handling
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -84,3 +84,5 @@ lint/tmp/
|
||||
|
||||
# Mac
|
||||
.DS_Store
|
||||
|
||||
CHANGELOG.md
|
||||
@@ -18,6 +18,7 @@ import java.security.spec.PKCS8EncodedKeySpec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.Inflater
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.google.crypto.tink.subtle.XChaCha20Poly1305
|
||||
|
||||
/**
|
||||
@@ -39,15 +40,10 @@ object CryptoManager {
|
||||
// Кэшируем производный ключ для каждого пароля чтобы не вычислять его каждый раз
|
||||
private val pbkdf2KeyCache = mutableMapOf<String, SecretKeySpec>()
|
||||
|
||||
// 🚀 ОПТИМИЗАЦИЯ: LRU-кэш для расшифрованных сообщений
|
||||
// Ключ = encryptedData, Значение = расшифрованный текст
|
||||
// Ограничиваем размер чтобы не съесть память
|
||||
private const val DECRYPTION_CACHE_SIZE = 500
|
||||
private val decryptionCache = object : LinkedHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean {
|
||||
return size > DECRYPTION_CACHE_SIZE
|
||||
}
|
||||
}
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free кэш для расшифрованных сообщений
|
||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной расшифровке
|
||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||
|
||||
init {
|
||||
// Add BouncyCastle provider for secp256k1 support
|
||||
@@ -73,9 +69,7 @@ object CryptoManager {
|
||||
*/
|
||||
fun clearCaches() {
|
||||
pbkdf2KeyCache.clear()
|
||||
synchronized(decryptionCache) {
|
||||
decryptionCache.clear()
|
||||
}
|
||||
decryptionCache.clear()
|
||||
keyPairCache.clear()
|
||||
privateKeyHashCache.clear()
|
||||
}
|
||||
@@ -306,20 +300,22 @@ object CryptoManager {
|
||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||
*/
|
||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Проверяем кэш расшифрованных сообщений
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||
val cacheKey = "$password:$encryptedData"
|
||||
synchronized(decryptionCache) {
|
||||
decryptionCache[cacheKey]?.let { return it }
|
||||
}
|
||||
decryptionCache[cacheKey]?.let { return it }
|
||||
|
||||
return try {
|
||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||
|
||||
// 🚀 Сохраняем в кэш
|
||||
// 🚀 Сохраняем в кэш (lock-free)
|
||||
if (result != null) {
|
||||
synchronized(decryptionCache) {
|
||||
decryptionCache[cacheKey] = result
|
||||
// Ограничиваем размер кэша
|
||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||
// Удаляем ~10% самых старых записей
|
||||
val keysToRemove = decryptionCache.keys.take(DECRYPTION_CACHE_SIZE / 10)
|
||||
keysToRemove.forEach { decryptionCache.remove(it) }
|
||||
}
|
||||
decryptionCache[cacheKey] = result
|
||||
}
|
||||
|
||||
result
|
||||
@@ -403,6 +399,11 @@ object CryptoManager {
|
||||
* Check if data is in old format (base64-encoded hex with ":")
|
||||
*/
|
||||
private fun isOldFormat(data: String): Boolean {
|
||||
// 🚀 Fast path: new format always contains ':' at plaintext level (iv:ct)
|
||||
// Old format is a single base64 blob without ':' in the encoded string
|
||||
if (data.contains(':')) return false
|
||||
if (data.startsWith("CHNK:")) return false
|
||||
|
||||
return try {
|
||||
val decoded = String(Base64.decode(data, Base64.NO_WRAP), Charsets.UTF_8)
|
||||
decoded.contains(":") && decoded.split(":").all { part ->
|
||||
|
||||
@@ -224,6 +224,9 @@ fun ChatDetailScreen(
|
||||
// 🖼 Состояние для multi-image editor (галерея)
|
||||
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||
|
||||
// Триггер для возврата фокуса на инпут после отправки фото из редактора
|
||||
var inputFocusTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// <20>📷 Camera launcher
|
||||
val cameraLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
@@ -1501,7 +1504,9 @@ fun ChatDetailScreen(
|
||||
opponentPublicKey =
|
||||
user.publicKey,
|
||||
myPrivateKey =
|
||||
currentUserPrivateKey
|
||||
currentUserPrivateKey,
|
||||
inputFocusTrigger =
|
||||
inputFocusTrigger
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1943,6 +1948,7 @@ fun ChatDetailScreen(
|
||||
onMediaSelectedWithCaption = { mediaItem, caption ->
|
||||
// 📸 Отправляем фото с caption напрямую
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
scope.launch {
|
||||
val base64 =
|
||||
MediaUtils.uriToBase64Image(
|
||||
@@ -2253,14 +2259,19 @@ fun ChatDetailScreen(
|
||||
pendingCameraPhotoUri?.let { uri ->
|
||||
ImageEditorScreen(
|
||||
imageUri = uri,
|
||||
onDismiss = { pendingCameraPhotoUri = null },
|
||||
onDismiss = {
|
||||
pendingCameraPhotoUri = null
|
||||
inputFocusTrigger++
|
||||
},
|
||||
onSave = { editedUri ->
|
||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||
viewModel.sendImageFromUri(editedUri, "")
|
||||
showMediaPicker = false
|
||||
},
|
||||
onSaveWithCaption = { editedUri, caption ->
|
||||
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
|
||||
viewModel.sendImageFromUri(editedUri, caption)
|
||||
showMediaPicker = false
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
showCaptionInput = true,
|
||||
@@ -2273,7 +2284,10 @@ fun ChatDetailScreen(
|
||||
if (pendingGalleryImages.isNotEmpty()) {
|
||||
MultiImageEditorScreen(
|
||||
imageUris = pendingGalleryImages,
|
||||
onDismiss = { pendingGalleryImages = emptyList() },
|
||||
onDismiss = {
|
||||
pendingGalleryImages = emptyList()
|
||||
inputFocusTrigger++
|
||||
},
|
||||
onSendAll = { imagesWithCaptions ->
|
||||
// 🚀 Мгновенный optimistic UI для каждого фото
|
||||
for (imageWithCaption in imagesWithCaptions) {
|
||||
@@ -2282,6 +2296,7 @@ fun ChatDetailScreen(
|
||||
imageWithCaption.caption
|
||||
)
|
||||
}
|
||||
showMediaPicker = false
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
recipientName = user.title
|
||||
|
||||
@@ -20,6 +20,8 @@ import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -36,7 +38,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
companion object {
|
||||
private const val TAG = "ChatViewModel"
|
||||
private const val PAGE_SIZE = 30
|
||||
private const val DECRYPT_CHUNK_SIZE = 5 // Расшифровываем по 5 сообщений за раз
|
||||
private const val DECRYPT_CHUNK_SIZE = 15 // Расшифровываем по 15 сообщений за раз
|
||||
private const val DECRYPT_PARALLELISM = 4 // Параллельная расшифровка
|
||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||
|
||||
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
|
||||
@@ -499,8 +502,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val hasForward = forwardMessages.isNotEmpty()
|
||||
if (hasForward) {}
|
||||
|
||||
// 🚀 P1: Проверяем кэш ДО сброса состояния — если есть, подставляем мгновенно (без flash)
|
||||
val account = myPublicKey
|
||||
val cachedMessages =
|
||||
if (account != null) {
|
||||
val dialogKey = getDialogKey(account, publicKey)
|
||||
dialogMessagesCache[dialogKey]
|
||||
} else null
|
||||
|
||||
// Сбрасываем состояние
|
||||
_messages.value = emptyList()
|
||||
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
|
||||
// Мгновенная подстановка из кэша — пользователь не увидит пустой экран
|
||||
_messages.value = cachedMessages
|
||||
_isLoading.value = false
|
||||
} else {
|
||||
// Нет кэша — показываем skeleton вместо пустого Lottie
|
||||
_isLoading.value = true
|
||||
_messages.value = emptyList()
|
||||
}
|
||||
_opponentOnline.value = false
|
||||
_opponentTyping.value = false
|
||||
typingTimeoutJob?.cancel()
|
||||
@@ -537,9 +556,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Подписываемся на онлайн статус
|
||||
subscribeToOnlineStatus()
|
||||
|
||||
// 🔥 ОПТИМИЗАЦИЯ: Загружаем сообщения ПОСЛЕ задержки для плавной анимации
|
||||
// 250ms - это время анимации перехода в чат
|
||||
loadMessagesFromDatabase(delayMs = 250L)
|
||||
// <EFBFBD> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
|
||||
loadMessagesFromDatabase(delayMs = 0L)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,19 +669,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
hasMoreMessages = entities.size >= PAGE_SIZE
|
||||
currentOffset = entities.size
|
||||
|
||||
// 🔥 ЧАНКОВАЯ расшифровка - по DECRYPT_CHUNK_SIZE сообщений с yield между
|
||||
// ними
|
||||
// Это предотвращает блокировку UI thread
|
||||
val messages = ArrayList<ChatMessage>(entities.size)
|
||||
// <EFBFBD> P2.2: ПАРАЛЛЕЛЬНАЯ расшифровка с Semaphore
|
||||
// Запускаем до DECRYPT_PARALLELISM одновременных расшифровок
|
||||
val reversedEntities = entities.asReversed()
|
||||
for ((index, entity) in reversedEntities.withIndex()) {
|
||||
val chatMsg = entityToChatMessage(entity)
|
||||
messages.add(chatMsg)
|
||||
|
||||
// Каждые DECRYPT_CHUNK_SIZE сообщений даём UI thread "подышать"
|
||||
if ((index + 1) % DECRYPT_CHUNK_SIZE == 0) {
|
||||
yield() // Позволяем другим корутинам выполниться
|
||||
}
|
||||
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||
val messages = coroutineScope {
|
||||
reversedEntities
|
||||
.map { entity ->
|
||||
async {
|
||||
semaphore.withPermit { entityToChatMessage(entity) }
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
|
||||
@@ -752,9 +769,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val newEntities = entities.filter { it.messageId !in existingIds }
|
||||
|
||||
if (newEntities.isNotEmpty()) {
|
||||
val newMessages = ArrayList<ChatMessage>(newEntities.size)
|
||||
for (entity in newEntities) {
|
||||
newMessages.add(entityToChatMessage(entity))
|
||||
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||
val newMessages = coroutineScope {
|
||||
newEntities
|
||||
.map { entity ->
|
||||
async { semaphore.withPermit { entityToChatMessage(entity) } }
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
|
||||
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
|
||||
@@ -840,8 +861,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
currentOffset += entities.size
|
||||
|
||||
if (entities.isNotEmpty()) {
|
||||
val semaphore = Semaphore(DECRYPT_PARALLELISM)
|
||||
val newMessages =
|
||||
entities.map { entity -> entityToChatMessage(entity) }.asReversed()
|
||||
coroutineScope {
|
||||
entities
|
||||
.map { entity ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
entityToChatMessage(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
.asReversed()
|
||||
|
||||
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
|
||||
// Это предотвращает потерю сообщений при повторном открытии диалога
|
||||
@@ -876,58 +909,81 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
*/
|
||||
private suspend fun entityToChatMessage(entity: MessageEntity): ChatMessage {
|
||||
|
||||
// 🚀 P2.1: Проверяем кэш расшифрованного текста по messageId
|
||||
val cachedText = decryptionCache[entity.messageId]
|
||||
|
||||
// Расшифровываем сообщение из content + chachaKey
|
||||
var plainKeyAndNonce: ByteArray? = null // 🚀 P2.3: Сохраняем для reply расшифровки
|
||||
var displayText =
|
||||
try {
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null &&
|
||||
entity.content.isNotEmpty() &&
|
||||
entity.chachaKey.isNotEmpty()
|
||||
) {
|
||||
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
||||
val decrypted =
|
||||
MessageCrypto.decryptIncoming(
|
||||
ciphertext = entity.content,
|
||||
encryptedKey = entity.chachaKey,
|
||||
myPrivateKey = privateKey
|
||||
)
|
||||
decrypted
|
||||
} else {
|
||||
// Fallback на расшифровку plainMessage с приватным ключом
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey)
|
||||
?: entity.plainMessage
|
||||
} catch (e: Exception) {
|
||||
if (cachedText != null) {
|
||||
cachedText
|
||||
} else
|
||||
try {
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null &&
|
||||
entity.content.isNotEmpty() &&
|
||||
entity.chachaKey.isNotEmpty()
|
||||
) {
|
||||
// Расшифровываем как в архиве: content + chachaKey + privateKey
|
||||
// 🚀 Используем Full версию чтобы получить plainKeyAndNonce для
|
||||
// reply
|
||||
val decryptResult =
|
||||
MessageCrypto.decryptIncomingFull(
|
||||
ciphertext = entity.content,
|
||||
encryptedKey = entity.chachaKey,
|
||||
myPrivateKey = privateKey
|
||||
)
|
||||
plainKeyAndNonce = decryptResult.plainKeyAndNonce
|
||||
val decrypted = decryptResult.plaintext
|
||||
// 🚀 Сохраняем в кэш
|
||||
decryptionCache[entity.messageId] = decrypted
|
||||
decrypted
|
||||
} else {
|
||||
// Fallback на расшифровку plainMessage с приватным ключом
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
val result =
|
||||
CryptoManager.decryptWithPassword(
|
||||
entity.plainMessage,
|
||||
privateKey
|
||||
)
|
||||
?: entity.plainMessage
|
||||
decryptionCache[entity.messageId] = result
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Пробуем расшифровать plainMessage
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(
|
||||
entity.plainMessage,
|
||||
privateKey
|
||||
)
|
||||
?: entity.plainMessage
|
||||
} catch (e2: Exception) {
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Пробуем расшифровать plainMessage
|
||||
val privateKey = myPrivateKey
|
||||
if (privateKey != null && entity.plainMessage.isNotEmpty()) {
|
||||
try {
|
||||
CryptoManager.decryptWithPassword(entity.plainMessage, privateKey)
|
||||
?: entity.plainMessage
|
||||
} catch (e2: Exception) {
|
||||
entity.plainMessage
|
||||
}
|
||||
} else {
|
||||
entity.plainMessage
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим attachments для поиска MESSAGES (цитата)
|
||||
// 🔥 ВАЖНО: Передаем content и chachaKey для расшифровки reply blob если нужно
|
||||
// <EFBFBD> P2.3: Передаём plainKeyAndNonce чтобы избежать повторного ECDH
|
||||
var replyData =
|
||||
parseReplyFromAttachments(
|
||||
attachmentsJson = entity.attachments,
|
||||
isFromMe = entity.fromMe == 1,
|
||||
content = entity.content,
|
||||
chachaKey = entity.chachaKey
|
||||
chachaKey = entity.chachaKey,
|
||||
plainKeyAndNonce = plainKeyAndNonce
|
||||
)
|
||||
|
||||
// Если не нашли reply в attachments, пробуем распарсить из текста
|
||||
@@ -1063,7 +1119,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachmentsJson: String,
|
||||
isFromMe: Boolean,
|
||||
content: String,
|
||||
chachaKey: String
|
||||
chachaKey: String,
|
||||
plainKeyAndNonce: ByteArray? =
|
||||
null // 🚀 P2.3: Переиспользуем ключ из основной расшифровки
|
||||
): ReplyData? {
|
||||
|
||||
if (attachmentsJson.isEmpty() || attachmentsJson == "[]") {
|
||||
@@ -1133,8 +1191,23 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// 🔥 Способ 3: Пробуем decryptReplyBlob с plainKeyAndNonce
|
||||
// <EFBFBD> Способ 3: Используем plainKeyAndNonce из основной расшифровки (без
|
||||
// повторного ECDH)
|
||||
if (!decryptionSuccess && plainKeyAndNonce != null) {
|
||||
try {
|
||||
val decrypted =
|
||||
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
|
||||
if (decrypted.isNotEmpty()) {
|
||||
dataJson = decrypted
|
||||
decryptionSuccess = true
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// 🔥 Способ 4 (fallback): Полный decryptIncomingFull если нет
|
||||
// plainKeyAndNonce
|
||||
if (!decryptionSuccess &&
|
||||
plainKeyAndNonce == null &&
|
||||
content.isNotEmpty() &&
|
||||
chachaKey.isNotEmpty() &&
|
||||
privateKey != null
|
||||
@@ -1146,9 +1219,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
chachaKey,
|
||||
privateKey
|
||||
)
|
||||
val plainKeyAndNonce = decryptResult.plainKeyAndNonce
|
||||
val keyAndNonce = decryptResult.plainKeyAndNonce
|
||||
val decrypted =
|
||||
MessageCrypto.decryptReplyBlob(dataJson, plainKeyAndNonce)
|
||||
MessageCrypto.decryptReplyBlob(dataJson, keyAndNonce)
|
||||
if (decrypted.isNotEmpty()) {
|
||||
dataJson = decrypted
|
||||
decryptionSuccess = true
|
||||
|
||||
@@ -85,7 +85,8 @@ fun MessageInputBar(
|
||||
onAttachClick: () -> Unit = {},
|
||||
myPublicKey: String = "",
|
||||
opponentPublicKey: String = "",
|
||||
myPrivateKey: String = ""
|
||||
myPrivateKey: String = "",
|
||||
inputFocusTrigger: Int = 0
|
||||
) {
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
@@ -111,6 +112,18 @@ fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// Return focus to input after closing the photo editor
|
||||
LaunchedEffect(inputFocusTrigger) {
|
||||
if (inputFocusTrigger > 0) {
|
||||
delay(100)
|
||||
editTextView?.let { editText ->
|
||||
editText.requestFocus()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val imeInsets = WindowInsets.ime
|
||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
||||
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
|
||||
|
||||
@@ -126,12 +126,15 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
fun setTextWithEmojis(newText: String) {
|
||||
if (newText == text.toString()) return
|
||||
isUpdating = true
|
||||
val cursorPos = selectionStart
|
||||
setText(newText)
|
||||
// Восстанавливаем позицию курсора в конец текста
|
||||
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
|
||||
setSelection(newCursorPos.coerceIn(0, newText.length))
|
||||
isUpdating = false
|
||||
try {
|
||||
val cursorPos = selectionStart
|
||||
setText(newText)
|
||||
// Восстанавливаем позицию курсора в конец текста
|
||||
val newCursorPos = if (cursorPos >= 0) newText.length else cursorPos
|
||||
setSelection(newCursorPos.coerceIn(0, newText.length))
|
||||
} finally {
|
||||
isUpdating = false
|
||||
}
|
||||
replaceEmojisWithImages(editableText)
|
||||
}
|
||||
|
||||
@@ -297,6 +300,10 @@ fun AppleEmojiTextField(
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
// Always update the callback to prevent stale lambda references
|
||||
// after recomposition (e.g., after sending a photo)
|
||||
view.onTextChange = onValueChange
|
||||
|
||||
if (view.text.toString() != value) {
|
||||
view.setTextWithEmojis(value)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ object OptimizedEmojiCache {
|
||||
private set
|
||||
|
||||
private const val PRELOAD_COUNT = 200 // Предзагружаем первые 200 эмодзи
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
/**
|
||||
* Предзагрузка при старте приложения
|
||||
|
||||
Reference in New Issue
Block a user