feat: Simplify animations across multiple screens by replacing slide transitions with fade animations for improved user experience
This commit is contained in:
@@ -59,6 +59,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||
@@ -474,8 +477,30 @@ fun ChatDetailScreen(
|
||||
// 🔥 Обработка системной кнопки назад
|
||||
BackHandler { hideKeyboardAndBack() }
|
||||
|
||||
// 🔥 Cleanup при выходе из экрана - только закрываем диалог
|
||||
DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } }
|
||||
// 🔥 Lifecycle-aware отслеживание активности экрана
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var isScreenActive by remember { mutableStateOf(true) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isScreenActive = true
|
||||
viewModel.setDialogActive(true)
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
isScreenActive = false
|
||||
viewModel.setDialogActive(false)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
viewModel.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем ViewModel с ключами и открываем диалог
|
||||
LaunchedEffect(user.publicKey) {
|
||||
@@ -489,9 +514,9 @@ fun ChatDetailScreen(
|
||||
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
|
||||
}
|
||||
|
||||
// Отмечаем сообщения как прочитанные когда они видны
|
||||
LaunchedEffect(messages) {
|
||||
if (messages.isNotEmpty()) {
|
||||
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
|
||||
LaunchedEffect(messages, isScreenActive) {
|
||||
if (messages.isNotEmpty() && isScreenActive) {
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
}
|
||||
}
|
||||
@@ -855,28 +880,44 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка меню - открывает bottom sheet
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Закрываем клавиатуру перед открытием меню
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
// Даём клавиатуре время закрыться перед показом
|
||||
// bottom sheet
|
||||
scope.launch {
|
||||
delay(
|
||||
150
|
||||
) // Задержка для плавного закрытия клавиатуры
|
||||
// Кнопка меню - открывает kebab menu
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Закрываем клавиатуру перед открытием меню
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus()
|
||||
showMenu = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More",
|
||||
tint = headerIconColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 🔥 TELEGRAM-STYLE KEBAB MENU
|
||||
KebabMenu(
|
||||
expanded = showMenu,
|
||||
onDismiss = { showMenu = false },
|
||||
isDarkTheme = isDarkTheme,
|
||||
isSavedMessages = isSavedMessages,
|
||||
isBlocked = isBlocked,
|
||||
onBlockClick = {
|
||||
showMenu = false
|
||||
showBlockConfirm = true
|
||||
},
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = "More",
|
||||
tint = headerIconColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(26.dp)
|
||||
onUnblockClick = {
|
||||
showMenu = false
|
||||
showUnblockConfirm = true
|
||||
},
|
||||
onDeleteClick = {
|
||||
showMenu = false
|
||||
showDeleteConfirm = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -927,29 +968,8 @@ fun ChatDetailScreen(
|
||||
AnimatedContent(
|
||||
targetState = isSelectionMode,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
// Selection mode появляется: снизу вверх
|
||||
slideInVertically(
|
||||
animationSpec = tween(300, easing = TelegramEasing),
|
||||
initialOffsetY = { it }
|
||||
) + fadeIn(animationSpec = tween(200)) togetherWith
|
||||
slideOutVertically(
|
||||
animationSpec =
|
||||
tween(300, easing = TelegramEasing),
|
||||
targetOffsetY = { -it }
|
||||
) + fadeOut(animationSpec = tween(150))
|
||||
} else {
|
||||
// Input bar возвращается: снизу вверх
|
||||
slideInVertically(
|
||||
animationSpec = tween(300, easing = TelegramEasing),
|
||||
initialOffsetY = { it }
|
||||
) + fadeIn(animationSpec = tween(200)) togetherWith
|
||||
slideOutVertically(
|
||||
animationSpec =
|
||||
tween(300, easing = TelegramEasing),
|
||||
targetOffsetY = { it }
|
||||
) + fadeOut(animationSpec = tween(150))
|
||||
}
|
||||
fadeIn(animationSpec = tween(200)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
label = "bottomBarContent"
|
||||
) { selectionMode ->
|
||||
@@ -1680,74 +1700,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// <EFBFBD> Bottom Sheet меню (вместо popup menu)
|
||||
if (showMenu) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showMenu = false },
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
scrimColor = Color.Black.copy(alpha = 0.6f),
|
||||
windowInsets =
|
||||
WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(vertical = 12.dp)
|
||||
.width(36.dp)
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.3f)
|
||||
else Color.Black.copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) {
|
||||
// Block/Unblock User
|
||||
if (!isSavedMessages) {
|
||||
BottomSheetMenuItem(
|
||||
icon =
|
||||
if (isBlocked) Icons.Default.CheckCircle
|
||||
else Icons.Default.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
onClick = {
|
||||
showMenu = false
|
||||
if (isBlocked) {
|
||||
showUnblockConfirm = true
|
||||
} else {
|
||||
showBlockConfirm = true
|
||||
}
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
tintColor = PrimaryBlue
|
||||
)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp,
|
||||
color =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f)
|
||||
else Color.Black.copy(alpha = 0.08f)
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Chat
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete Chat",
|
||||
onClick = {
|
||||
showMenu = false
|
||||
showDeleteConfirm = true
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
isDestructive = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// <20>📨 Forward Chat Picker BottomSheet
|
||||
// 📨 Forward Chat Picker BottomSheet
|
||||
if (showForwardPicker) {
|
||||
ForwardChatPickerBottomSheet(
|
||||
dialogs = dialogsList,
|
||||
@@ -3180,3 +3133,108 @@ private fun BottomSheetMenuItem(
|
||||
Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 TELEGRAM-STYLE KEBAB MENU
|
||||
* Красивое выпадающее меню с анимациями и современным дизайном
|
||||
*/
|
||||
@Composable
|
||||
private fun KebabMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
isSavedMessages: Boolean,
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val menuBackgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val dividerColor = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.width(220.dp),
|
||||
properties = PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
// Block/Unblock User (не для Saved Messages)
|
||||
if (!isSavedMessages) {
|
||||
KebabMenuItem(
|
||||
icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block,
|
||||
text = if (isBlocked) "Unblock User" else "Block User",
|
||||
onClick = {
|
||||
if (isBlocked) onUnblockClick() else onBlockClick()
|
||||
},
|
||||
tintColor = PrimaryBlue,
|
||||
textColor = textColor
|
||||
)
|
||||
|
||||
// Divider
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.height(0.5.dp)
|
||||
.background(dividerColor)
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Chat
|
||||
KebabMenuItem(
|
||||
icon = Icons.Default.Delete,
|
||||
text = "Delete Chat",
|
||||
onClick = onDeleteClick,
|
||||
tintColor = Color(0xFFFF3B30),
|
||||
textColor = Color(0xFFFF3B30)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Элемент Kebab меню
|
||||
*/
|
||||
@Composable
|
||||
private fun KebabMenuItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
tintColor: Color,
|
||||
textColor: Color
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tintColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp),
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Как currentDialogPublicKeyView в архиве
|
||||
private var isDialogActive = false
|
||||
|
||||
// 🟢 Флаг что уже подписаны на онлайн статус собеседника
|
||||
private var subscribedToOnlineStatus = false
|
||||
|
||||
init {
|
||||
setupPacketListeners()
|
||||
setupNewMessageListener()
|
||||
@@ -351,6 +354,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isLoadingMessages = false
|
||||
lastReadMessageTimestamp = 0L
|
||||
readReceiptSentForCurrentDialog = false
|
||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
|
||||
isDialogActive = true // 🔥 Диалог активен!
|
||||
|
||||
// 📨 Применяем Forward сообщения СРАЗУ после сброса
|
||||
@@ -390,6 +394,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
isDialogActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить состояние активности диалога (вызывается при ON_RESUME/ON_PAUSE)
|
||||
* Предотвращает отметку сообщений как прочитанных когда приложение в фоне
|
||||
*/
|
||||
fun setDialogActive(active: Boolean) {
|
||||
isDialogActive = active
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 СУПЕР-оптимизированная загрузка сообщений
|
||||
* 🔥 ОПТИМИЗАЦИЯ: Кэш на уровне диалога + задержка для анимации + чанковая расшифровка
|
||||
@@ -1428,8 +1440,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/**
|
||||
* 🟢 Подписаться на онлайн статус собеседника
|
||||
* 📁 Для Saved Messages - не подписываемся
|
||||
* 🔥 Проверяем флаг чтобы не подписываться повторно
|
||||
*/
|
||||
fun subscribeToOnlineStatus() {
|
||||
// 🔥 Если уже подписаны - не подписываемся повторно!
|
||||
if (subscribedToOnlineStatus) return
|
||||
|
||||
val opponent = opponentKey ?: return
|
||||
val privateKey = myPrivateKey ?: return
|
||||
val account = myPublicKey ?: return
|
||||
@@ -1439,6 +1455,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Устанавливаем флаг ДО отправки пакета
|
||||
subscribedToOnlineStatus = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
@@ -1460,6 +1479,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
super.onCleared()
|
||||
lastReadMessageTimestamp = 0L
|
||||
readReceiptSentForCurrentDialog = false
|
||||
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг при очистке
|
||||
opponentKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,11 @@ fun ChatsListScreen(
|
||||
}
|
||||
*/
|
||||
|
||||
// Enable UI logs when status dialog is shown
|
||||
LaunchedEffect(showStatusDialog) {
|
||||
ProtocolManager.enableUILogs(showStatusDialog)
|
||||
}
|
||||
|
||||
// Status dialog with logs
|
||||
if (showStatusDialog) {
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
@@ -576,8 +581,12 @@ fun ChatsListScreen(
|
||||
iconColor = menuIconColor,
|
||||
textColor = textColor,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
onSavedMessagesClick()
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
// Ждём завершения анимации закрытия drawer
|
||||
kotlinx.coroutines.delay(250)
|
||||
onSavedMessagesClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -893,37 +902,8 @@ fun ChatsListScreen(
|
||||
AnimatedContent(
|
||||
targetState = showRequestsScreen,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
// Переход на Requests: быстрый slide + fade
|
||||
(slideInHorizontally(
|
||||
animationSpec =
|
||||
tween(200, easing = FastOutSlowInEasing),
|
||||
initialOffsetX = { it }
|
||||
) + fadeIn(tween(150))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
animationSpec =
|
||||
tween(
|
||||
200,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
targetOffsetX = { -it / 4 }
|
||||
) + fadeOut(tween(100)))
|
||||
} else {
|
||||
// Возврат из Requests: slide out to right
|
||||
(slideInHorizontally(
|
||||
animationSpec =
|
||||
tween(200, easing = FastOutSlowInEasing),
|
||||
initialOffsetX = { -it / 4 }
|
||||
) + fadeIn(tween(150))) togetherWith
|
||||
(slideOutHorizontally(
|
||||
animationSpec =
|
||||
tween(
|
||||
200,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
targetOffsetX = { it }
|
||||
) + fadeOut(tween(100)))
|
||||
}
|
||||
fadeIn(animationSpec = tween(200)) togetherWith
|
||||
fadeOut(animationSpec = tween(150))
|
||||
},
|
||||
label = "RequestsTransition"
|
||||
) { isRequestsScreen ->
|
||||
@@ -1662,11 +1642,63 @@ fun DialogItemContent(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
||||
fontSize = 13.sp,
|
||||
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
// Показываем статус только для исходящих сообщений
|
||||
if (dialog.lastMessageFromMe == 1) {
|
||||
when (dialog.lastMessageDelivered) {
|
||||
2 -> {
|
||||
// ERROR - показываем иконку ошибки
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.ErrorOutline,
|
||||
contentDescription = "Sending failed",
|
||||
tint = Color(0xFFFF3B30), // iOS красный
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
1 -> {
|
||||
// DELIVERED - две галочки
|
||||
val checkmarkColor = if (dialog.lastMessageRead == 1) {
|
||||
PrimaryBlue // прочитано - синие
|
||||
} else {
|
||||
secondaryTextColor.copy(alpha = 0.6f) // доставлено - серые
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
tint = checkmarkColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
tint = checkmarkColor,
|
||||
modifier = Modifier.size(14.dp).offset(x = (-6).dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
}
|
||||
0 -> {
|
||||
// WAITING - одна серая галочка (отправлено, ждём доставку)
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = formatTime(Date(dialog.lastMessageTimestamp)),
|
||||
fontSize = 13.sp,
|
||||
color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -30,7 +30,10 @@ data class DialogUiModel(
|
||||
val isOnline: Int,
|
||||
val lastSeen: Long,
|
||||
val verified: Int,
|
||||
val isSavedMessages: Boolean = false // 📁 Флаг для Saved Messages (account == opponentKey)
|
||||
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
|
||||
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
|
||||
val lastMessageRead: Int = 0 // Прочитано (0/1)
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -61,6 +64,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// 🔥 Tracking для уже запрошенных user info - предотвращает бесконечные запросы
|
||||
private val requestedUserInfoKeys = mutableSetOf<String>()
|
||||
|
||||
// 🟢 Tracking для уже подписанных онлайн-статусов - предотвращает бесконечный цикл
|
||||
private val subscribedOnlineKeys = mutableSetOf<String>()
|
||||
|
||||
// Список диалогов с расшифрованными сообщениями
|
||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||
val dialogs: StateFlow<List<DialogUiModel>> = _dialogs.asStateFlow()
|
||||
@@ -97,6 +103,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (currentAccount == publicKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
requestedUserInfoKeys.clear()
|
||||
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
|
||||
@@ -138,7 +148,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages = isSavedMessages // 📁 Saved Messages
|
||||
isSavedMessages = isSavedMessages, // 📁 Saved Messages
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered = dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -194,7 +207,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages = (dialog.account == dialog.opponentKey) // 📁 Saved Messages
|
||||
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered = dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,17 +232,25 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
/**
|
||||
* 🟢 Подписаться на онлайн-статусы всех собеседников
|
||||
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
|
||||
*/
|
||||
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||
if (opponentKeys.isEmpty()) return
|
||||
|
||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
|
||||
if (newKeys.isEmpty()) return // Все уже подписаны
|
||||
|
||||
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
|
||||
subscribedOnlineKeys.addAll(newKeys)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
val packet = PacketOnlineSubscribe().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
opponentKeys.forEach { key ->
|
||||
newKeys.forEach { key ->
|
||||
addPublicKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user