feat: enhance avatar handling and file attachment functionality with improved UI interactions

This commit is contained in:
2026-02-15 11:54:09 +05:00
parent e301478d92
commit b543ef4d6f
11 changed files with 177 additions and 101 deletions

View File

@@ -72,7 +72,8 @@ fun ImportSeedPhraseScreen(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 24.dp),
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
@@ -233,8 +234,8 @@ fun ImportSeedPhraseScreen(
}
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(24.dp))
// Import button
AnimatedVisibility(
visible = visible,

View File

@@ -43,15 +43,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (dialogKey -> List<ChatMessage>)
// Сделан глобальным чтобы можно было очистить при удалении диалога
// 🔥 ГЛОБАЛЬНЫЙ кэш сообщений на уровне диалогов (account|dialogKey -> List<ChatMessage>)
// Ключ включает account для изоляции данных между аккаунтами
private val dialogMessagesCache = ConcurrentHashMap<String, List<ChatMessage>>()
/** Формирует ключ кэша с привязкой к аккаунту */
private fun cacheKey(account: String, dialogKey: String) = "$account|$dialogKey"
/**
* 🔥 Обновить кэш с ограничением размера Сохраняет только последние MAX_CACHE_SIZE
* сообщений для предотвращения OOM
*/
private fun updateCacheWithLimit(dialogKey: String, messages: List<ChatMessage>) {
private fun updateCacheWithLimit(account: String, dialogKey: String, messages: List<ChatMessage>) {
val limitedMessages =
if (messages.size > MAX_CACHE_SIZE) {
// Оставляем только последние сообщения (по timestamp)
@@ -61,12 +64,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} else {
messages
}
dialogMessagesCache[dialogKey] = limitedMessages
dialogMessagesCache[cacheKey(account, dialogKey)] = limitedMessages
}
/** 🗑️ Очистить кэш сообщений для диалога Вызывается при удалении диалога */
fun clearDialogCache(dialogKey: String) {
dialogMessagesCache.remove(dialogKey)
// Очищаем для всех аккаунтов — при удалении диалога dialogKey не привязан к аккаунту
val keysToRemove = dialogMessagesCache.keys.filter { it.endsWith("|$dialogKey") }
keysToRemove.forEach { dialogMessagesCache.remove(it) }
}
/** 🗑️ Очистить кэш по publicKey собеседника Удаляет все ключи содержащие этот publicKey */
@@ -74,6 +79,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val keysToRemove = dialogMessagesCache.keys.filter { it.contains(opponentKey) }
keysToRemove.forEach { dialogMessagesCache.remove(it) }
}
/** 🗑️ Полная очистка кэша — вызывается при переключении аккаунта */
fun clearAllDialogCache() {
dialogMessagesCache.clear()
}
}
// Database
@@ -322,11 +332,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// Обновляем кэш
val cachedMessages = dialogMessagesCache[dialogKey]
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null) {
updateCacheWithLimit(dialogKey, cachedMessages + newMessages)
updateCacheWithLimit(account, dialogKey, cachedMessages + newMessages)
} else {
updateCacheWithLimit(dialogKey, _messages.value)
updateCacheWithLimit(account, dialogKey, _messages.value)
}
currentOffset += newMessages.size
@@ -450,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val account = myPublicKey ?: return
val opponent = opponentKey ?: return
val dialogKey = getDialogKey(account, opponent)
updateCacheWithLimit(dialogKey, _messages.value)
updateCacheWithLimit(account, dialogKey, _messages.value)
}
/** Обновить статус сообщения в БД */
@@ -480,6 +490,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Установить ключи пользователя */
fun setUserKeys(publicKey: String, privateKey: String) {
if (myPublicKey != publicKey) {
// Clear caches on account switch to prevent cross-account data leakage
// Безусловная очистка (даже если myPublicKey == null) — свежий ViewModel
// может получить стейтный кэш от предыдущего аккаунта
dialogMessagesCache.clear()
decryptionCache.clear()
}
myPublicKey = publicKey
myPrivateKey = privateKey
}
@@ -510,7 +527,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val cachedMessages =
if (account != null) {
val dialogKey = getDialogKey(account, publicKey)
dialogMessagesCache[dialogKey]
dialogMessagesCache[cacheKey(account, dialogKey)]
} else null
// Сбрасываем состояние
@@ -602,7 +619,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
val cachedMessages = dialogMessagesCache[dialogKey]
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
if (cachedMessages != null && cachedMessages.isNotEmpty()) {
withContext(Dispatchers.Main.immediate) {
@@ -687,7 +704,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 🔥 Сохраняем в кэш для мгновенной повторной загрузки!
updateCacheWithLimit(dialogKey, messages.toList())
updateCacheWithLimit(account, dialogKey, messages.toList())
// 🔥 СРАЗУ обновляем UI - пользователь видит сообщения мгновенно
// НО сохраняем оптимистичные сообщения (SENDING), которые ещё не в БД
@@ -787,11 +804,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
(existingCache + trulyNewMessages).sortedBy { it.timestamp }
)
@@ -881,11 +899,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш при загрузке старых сообщений!
// Это предотвращает потерю сообщений при повторном открытии диалога
val existingCache = dialogMessagesCache[dialogKey] ?: emptyList()
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
(trulyNewMessages + existingCache).sortedBy { it.timestamp }
)
@@ -3558,7 +3577,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Очищаем кэш
val dialogKey = getDialogKey(account, opponent)
dialogMessagesCache.remove(dialogKey)
dialogMessagesCache.remove(cacheKey(account, dialogKey))
// Очищаем UI
withContext(Dispatchers.Main) { _messages.value = emptyList() }

View File

@@ -49,11 +49,9 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.painter.Painter
@@ -494,29 +492,15 @@ fun ChatsListScreen(
)
val headerColor = avatarColors.backgroundColor
// Header: avatar blur или цвет шапки chat list
// Header: цвет шапки сайдбара
Box(modifier = Modifier.fillMaxWidth()) {
if (backgroundBlurColorId == "avatar") {
// Avatar blur
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue,
blurRadius = 40f,
alpha = 0.6f,
overlayColors = null,
isDarkTheme = isDarkTheme
)
} else {
// None или любой другой — стандартный цвет шапки
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
}
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2A) else PrimaryBlue
)
)
// Content поверх фона
Column(
@@ -1277,21 +1261,7 @@ fun ChatsListScreen(
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
// 🔥 ИСПРАВЛЕНИЕ МЕРЦАНИЯ: Запоминаем, что контент УЖЕ был
// показан
// Это предотвращает показ EmptyState при временных пустых
// обновлениях
var hasShownContent by rememberSaveable {
mutableStateOf(false)
}
if (chatsState.hasContent) {
hasShownContent = true
}
// 🎯 Показываем Empty State только если контент НИКОГДА не
// показывался
val shouldShowEmptyState =
chatsState.isEmpty && !hasShownContent
val showSkeleton = isLoading
// 🎬 Animated content transition between main list and
// requests
@@ -1427,12 +1397,9 @@ fun ChatsListScreen(
}
)
} // Close Box wrapper
} else if (isLoading) {
// 🚀 Shimmer skeleton пока данные грузятся
} else if (showSkeleton) {
ChatsListSkeleton(isDarkTheme = isDarkTheme)
} else if (shouldShowEmptyState) {
// 🔥 Empty state - показываем только если
// контент НЕ был показан ранее
} else if (chatsState.isEmpty) {
EmptyChatsState(
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()

View File

@@ -14,6 +14,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
@@ -73,6 +74,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
private val subscribedOnlineKeys = mutableSetOf<String>()
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
@@ -124,16 +128,32 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
subscribedOnlineKeys.clear()
// 🔥 Очищаем глобальный кэш сообщений ChatViewModel при смене аккаунта
// чтобы избежать показа сообщений с неправильным isOutgoing
ChatViewModel.clearAllDialogCache()
// Отменяем старые подписки от предыдущего аккаунта
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
currentPrivateKey = privateKey
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
_dialogs.value = emptyList()
_requests.value = emptyList()
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
viewModelScope.launch {
launch {
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
@@ -293,7 +313,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
viewModelScope.launch {
launch {
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
@@ -422,7 +442,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 📊 Подписываемся на количество requests
viewModelScope.launch {
launch {
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
@@ -432,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
// blockUser()/unblockUser()
viewModelScope.launch {
launch {
database.blacklistDao()
.getBlockedUsers(publicKey)
.flowOn(Dispatchers.IO)
@@ -440,6 +460,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.distinctUntilChanged()
.collect { blockedSet -> _blockedUsers.value = blockedSet }
}
} // end accountSubscriptionsJob
}
/**

View File

@@ -64,7 +64,11 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import kotlin.math.min
private const val TAG = "AttachmentComponents"
@@ -1344,13 +1348,37 @@ fun FileAttachment(
label = "progress"
)
// Путь к скачанному файлу (как Desktop: ~/Rosetta Downloads/filename)
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
LaunchedEffect(attachment.id) {
downloadStatus =
if (isDownloadTag(preview)) {
DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.DOWNLOADED
}
downloadStatus = if (isDownloadTag(preview)) {
// Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED
else DownloadStatus.NOT_DOWNLOADED
} else {
DownloadStatus.DOWNLOADED
}
}
// Открыть файл через системное приложение
val openFile: () -> Unit = {
try {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
savedFile
)
val ext = fileName.substringAfterLast('.', "").lowercase()
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
?: "application/octet-stream"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
val download: () -> Unit = {
@@ -1364,8 +1392,6 @@ fun FileAttachment(
downloadStatus = DownloadStatus.DECRYPTING
// КРИТИЧНО: chachaKey ЗАШИФРОВАН в БД (как в Desktop)
// Сначала расшифровываем его, получаем raw bytes
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
@@ -1377,7 +1403,16 @@ fun FileAttachment(
downloadProgress = 0.9f
if (decrypted != null) {
// TODO: Save to Downloads folder
withContext(Dispatchers.IO) {
// Декодим base64 в байты (обработка data URL и plain base64)
val base64Data = if (decrypted.contains(",")) {
decrypted.substringAfter(",")
} else {
decrypted
}
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
savedFile.writeBytes(bytes)
}
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
} else {
@@ -1398,8 +1433,14 @@ fun FileAttachment(
.clickable(
enabled =
downloadStatus == DownloadStatus.NOT_DOWNLOADED ||
downloadStatus == DownloadStatus.DOWNLOADED ||
downloadStatus == DownloadStatus.ERROR
) { download() }
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADED -> openFile()
else -> download()
}
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -42,12 +42,6 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true
) {
// В светлой теме с дефолтным фоном (avatar, без overlay) — синий как шапка chat list
if (!isDarkTheme && (overlayColors == null || overlayColors.isEmpty())) {
Box(modifier = Modifier.matchParentSize().background(Color(0xFF0D8CF4)))
return
}
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) {