feat: Enhance ChatDetailScreen and ChatViewModel with dynamic online status and typing indicators
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -8,6 +9,7 @@ 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.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -63,13 +65,33 @@ data class ChatMessage(
|
|||||||
val text: String,
|
val text: String,
|
||||||
val isOutgoing: Boolean,
|
val isOutgoing: Boolean,
|
||||||
val timestamp: Date,
|
val timestamp: Date,
|
||||||
val status: MessageStatus = MessageStatus.SENT
|
val status: MessageStatus = MessageStatus.SENT,
|
||||||
|
val showDateHeader: Boolean = false // Показывать ли разделитель даты
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MessageStatus {
|
enum class MessageStatus {
|
||||||
SENDING, SENT, DELIVERED, READ
|
SENDING, SENT, DELIVERED, READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить текст даты (today, yesterday или полная дата)
|
||||||
|
*/
|
||||||
|
private fun getDateText(timestamp: Long): String {
|
||||||
|
val messageDate = Calendar.getInstance().apply { timeInMillis = timestamp }
|
||||||
|
val today = Calendar.getInstance()
|
||||||
|
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
|
||||||
|
|
||||||
|
return when {
|
||||||
|
messageDate.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||||
|
messageDate.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR) -> "today"
|
||||||
|
|
||||||
|
messageDate.get(Calendar.YEAR) == yesterday.get(Calendar.YEAR) &&
|
||||||
|
messageDate.get(Calendar.DAY_OF_YEAR) == yesterday.get(Calendar.DAY_OF_YEAR) -> "yesterday"
|
||||||
|
|
||||||
|
else -> SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extension для конвертации
|
// Extension для конвертации
|
||||||
private fun Message.toChatMessage() = ChatMessage(
|
private fun Message.toChatMessage() = ChatMessage(
|
||||||
id = messageId,
|
id = messageId,
|
||||||
@@ -98,15 +120,38 @@ fun ChatDetailScreen(
|
|||||||
viewModel: ChatViewModel = viewModel()
|
viewModel: ChatViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
val inputBackgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
|
||||||
|
|
||||||
|
// <20> Fade-in анимация для всего экрана
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
val screenAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (isVisible) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||||
|
label = "screenFade"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Быстрое закрытие клавиатуры и выход
|
||||||
|
val hideKeyboardAndBack = remember {
|
||||||
|
{
|
||||||
|
// Мгновенно убираем фокус и клавиатуру
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
keyboardController?.hide()
|
||||||
|
// Сразу выходим без задержки
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Определяем это Saved Messages или обычный чат
|
// Определяем это Saved Messages или обычный чат
|
||||||
val isSavedMessages = user.publicKey == currentUserPublicKey
|
val isSavedMessages = user.publicKey == currentUserPublicKey
|
||||||
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
|
val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) }
|
||||||
val chatSubtitle = if (isSavedMessages) "Notes" else if (user.online == 1) "online" else "last seen recently"
|
|
||||||
|
|
||||||
// Состояние показа логов
|
// Состояние показа логов
|
||||||
var showLogs by remember { mutableStateOf(false) }
|
var showLogs by remember { mutableStateOf(false) }
|
||||||
@@ -116,16 +161,63 @@ fun ChatDetailScreen(
|
|||||||
val messages by viewModel.messages.collectAsState()
|
val messages by viewModel.messages.collectAsState()
|
||||||
val inputText by viewModel.inputText.collectAsState()
|
val inputText by viewModel.inputText.collectAsState()
|
||||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||||
|
val isOnline by viewModel.opponentOnline.collectAsState()
|
||||||
|
|
||||||
|
// 🔥 Добавляем информацию о датах к сообщениям
|
||||||
|
val messagesWithDates = remember(messages) {
|
||||||
|
val result = mutableListOf<Pair<ChatMessage, Boolean>>() // message, showDateHeader
|
||||||
|
var lastDateString = ""
|
||||||
|
|
||||||
|
// Сортируем по времени (старые -> новые)
|
||||||
|
val sortedMessages = messages.sortedBy { it.timestamp.time }
|
||||||
|
|
||||||
|
for (message in sortedMessages) {
|
||||||
|
val dateString = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(message.timestamp)
|
||||||
|
val showDate = dateString != lastDateString
|
||||||
|
result.add(message to showDate)
|
||||||
|
lastDateString = dateString
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Динамический subtitle: typing > online > offline
|
||||||
|
val chatSubtitle = when {
|
||||||
|
isSavedMessages -> "Notes"
|
||||||
|
isTyping -> "typing..."
|
||||||
|
isOnline -> "online"
|
||||||
|
else -> "offline"
|
||||||
|
}
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// 🔥 Обработка системной кнопки назад
|
||||||
|
BackHandler {
|
||||||
|
hideKeyboardAndBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Cleanup при выходе из экрана
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Инициализируем ViewModel с ключами и открываем диалог
|
// Инициализируем ViewModel с ключами и открываем диалог
|
||||||
LaunchedEffect(user.publicKey) {
|
LaunchedEffect(user.publicKey) {
|
||||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||||
viewModel.openDialog(user.publicKey, user.title, user.username)
|
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отмечаем сообщения как прочитанные когда они видны
|
||||||
|
LaunchedEffect(messages) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
viewModel.markVisibleMessagesAsRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Прокрутка при новых сообщениях
|
// Прокрутка при новых сообщениях
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
@@ -139,14 +231,20 @@ fun ChatDetailScreen(
|
|||||||
isDarkTheme
|
isDarkTheme
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
// 🚀 Весь контент с fade-in анимацией
|
||||||
topBar = {
|
Box(
|
||||||
// Кастомный TopAppBar для чата
|
modifier = Modifier
|
||||||
Surface(
|
.fillMaxSize()
|
||||||
color = backgroundColor,
|
.graphicsLayer { alpha = screenAlpha }
|
||||||
shadowElevation = 0.dp
|
) {
|
||||||
) {
|
Scaffold(
|
||||||
Row(
|
topBar = {
|
||||||
|
// Кастомный TopAppBar для чата
|
||||||
|
Surface(
|
||||||
|
color = backgroundColor,
|
||||||
|
shadowElevation = 0.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
@@ -155,10 +253,7 @@ fun ChatDetailScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Кнопка назад
|
// Кнопка назад
|
||||||
IconButton(onClick = {
|
IconButton(onClick = hideKeyboardAndBack) {
|
||||||
keyboardController?.hide()
|
|
||||||
onBack()
|
|
||||||
}) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ArrowBack,
|
Icons.Default.ArrowBack,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
@@ -217,7 +312,12 @@ fun ChatDetailScreen(
|
|||||||
Text(
|
Text(
|
||||||
text = chatSubtitle,
|
text = chatSubtitle,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = if (!isSavedMessages && user.online == 1) PrimaryBlue else secondaryTextColor,
|
color = when {
|
||||||
|
isSavedMessages -> secondaryTextColor
|
||||||
|
isTyping -> PrimaryBlue // Синий когда печатает
|
||||||
|
isOnline -> Color(0xFF38B24D) // Зелёный когда онлайн
|
||||||
|
else -> secondaryTextColor // Серый для offline
|
||||||
|
},
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -307,11 +407,24 @@ fun ChatDetailScreen(
|
|||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
items(messages.reversed(), key = { it.id }) { message ->
|
// Для inverted FlatList: идём от новых к старым
|
||||||
MessageBubble(
|
val reversedMessages = messagesWithDates.reversed()
|
||||||
message = message,
|
itemsIndexed(reversedMessages, key = { _, item -> item.first.id }) { index, (message, showDate) ->
|
||||||
isDarkTheme = isDarkTheme
|
// В inverted списке дата показывается ПЕРЕД сообщением (визуально ПОСЛЕ)
|
||||||
)
|
Column {
|
||||||
|
MessageBubble(
|
||||||
|
message = message,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
index = index
|
||||||
|
)
|
||||||
|
// Разделитель даты
|
||||||
|
if (showDate) {
|
||||||
|
DateHeader(
|
||||||
|
dateText = getDateText(message.timestamp.time),
|
||||||
|
secondaryTextColor = secondaryTextColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,7 +433,13 @@ fun ChatDetailScreen(
|
|||||||
// Поле ввода сообщения
|
// Поле ввода сообщения
|
||||||
MessageInputBar(
|
MessageInputBar(
|
||||||
value = inputText,
|
value = inputText,
|
||||||
onValueChange = { viewModel.updateInputText(it) },
|
onValueChange = {
|
||||||
|
viewModel.updateInputText(it)
|
||||||
|
// Отправляем индикатор печатания
|
||||||
|
if (it.isNotEmpty() && !isSavedMessages) {
|
||||||
|
viewModel.sendTypingIndicator()
|
||||||
|
}
|
||||||
|
},
|
||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage()
|
viewModel.sendMessage()
|
||||||
ProtocolManager.addLog("📤 Sending message...")
|
ProtocolManager.addLog("📤 Sending message...")
|
||||||
@@ -332,6 +451,7 @@ fun ChatDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} // Закрытие Box с fade-in
|
||||||
|
|
||||||
// Диалог логов
|
// Диалог логов
|
||||||
if (showLogs) {
|
if (showLogs) {
|
||||||
@@ -384,13 +504,38 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Пузырек сообщения
|
* 🚀 Пузырек сообщения с fade-in анимацией
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MessageBubble(
|
private fun MessageBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
isDarkTheme: Boolean
|
isDarkTheme: Boolean,
|
||||||
|
index: Int = 0 // Для staggered анимации
|
||||||
) {
|
) {
|
||||||
|
// 🔥 Fade-in + slide анимация
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (isVisible) 1f else 0f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
delayMillis = minOf(index * 20, 200), // Staggered, max 200ms delay
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
),
|
||||||
|
label = "bubbleAlpha"
|
||||||
|
)
|
||||||
|
val offsetY by animateFloatAsState(
|
||||||
|
targetValue = if (isVisible) 0f else 20f,
|
||||||
|
animationSpec = tween(
|
||||||
|
durationMillis = 150,
|
||||||
|
delayMillis = minOf(index * 20, 200),
|
||||||
|
easing = FastOutSlowInEasing
|
||||||
|
),
|
||||||
|
label = "bubbleOffset"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(message.id) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
val bubbleColor = if (message.isOutgoing) {
|
val bubbleColor = if (message.isOutgoing) {
|
||||||
PrimaryBlue
|
PrimaryBlue
|
||||||
} else {
|
} else {
|
||||||
@@ -408,7 +553,11 @@ private fun MessageBubble(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
this.alpha = alpha
|
||||||
|
translationY = offsetY
|
||||||
|
},
|
||||||
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -464,6 +613,48 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🚀 Разделитель даты с fade-in анимацией
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DateHeader(
|
||||||
|
dateText: String,
|
||||||
|
secondaryTextColor: Color
|
||||||
|
) {
|
||||||
|
// Fade-in анимация
|
||||||
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
val alpha by animateFloatAsState(
|
||||||
|
targetValue = if (isVisible) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing),
|
||||||
|
label = "dateAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(dateText) {
|
||||||
|
isVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp)
|
||||||
|
.graphicsLayer { this.alpha = alpha },
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dateText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.1f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Панель ввода сообщения 1:1 как в React Native
|
* Панель ввода сообщения 1:1 как в React Native
|
||||||
* Оптимизированная версия с правильным позиционированием
|
* Оптимизированная версия с правильным позиционированием
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private val _opponentTyping = MutableStateFlow(false)
|
private val _opponentTyping = MutableStateFlow(false)
|
||||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||||
|
|
||||||
|
// 🟢 Онлайн статус собеседника
|
||||||
|
private val _opponentOnline = MutableStateFlow(false)
|
||||||
|
val opponentOnline: StateFlow<Boolean> = _opponentOnline.asStateFlow()
|
||||||
|
|
||||||
|
private val _opponentLastSeen = MutableStateFlow(0L)
|
||||||
|
val opponentLastSeen: StateFlow<Long> = _opponentLastSeen.asStateFlow()
|
||||||
|
|
||||||
// Input state
|
// Input state
|
||||||
private val _inputText = MutableStateFlow("")
|
private val _inputText = MutableStateFlow("")
|
||||||
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
val inputText: StateFlow<String> = _inputText.asStateFlow()
|
||||||
@@ -78,6 +85,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Job для отмены загрузки при смене диалога
|
// Job для отмены загрузки при смене диалога
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
|
||||||
|
// 🔥 Throttling для typing индикатора
|
||||||
|
private var lastTypingSentTime = 0L
|
||||||
|
private val TYPING_THROTTLE_MS = 2000L // Отправляем не чаще чем раз в 2 сек
|
||||||
|
|
||||||
|
// Отслеживание прочитанных сообщений
|
||||||
|
private val sentReadReceipts = mutableSetOf<String>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setupPacketListeners()
|
setupPacketListeners()
|
||||||
}
|
}
|
||||||
@@ -96,8 +110,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Доставка
|
// Доставка
|
||||||
ProtocolManager.waitPacket(0x08) { packet ->
|
ProtocolManager.waitPacket(0x08) { packet ->
|
||||||
val deliveryPacket = packet as PacketDelivery
|
val deliveryPacket = packet as PacketDelivery
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
// Обновляем в БД
|
||||||
|
updateMessageStatusInDb(deliveryPacket.messageId, 1) // DELIVERED
|
||||||
|
// Обновляем UI
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(deliveryPacket.messageId, MessageStatus.DELIVERED)
|
||||||
|
}
|
||||||
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
|
ProtocolManager.addLog("✓ Delivered: ${deliveryPacket.messageId.take(8)}...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,8 +124,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Прочитано
|
// Прочитано
|
||||||
ProtocolManager.waitPacket(0x07) { packet ->
|
ProtocolManager.waitPacket(0x07) { packet ->
|
||||||
val readPacket = packet as PacketRead
|
val readPacket = packet as PacketRead
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
updateMessageStatus(readPacket.messageId, MessageStatus.READ)
|
// Обновляем в БД
|
||||||
|
updateMessageStatusInDb(readPacket.messageId, 3) // READ
|
||||||
|
// Обновляем UI
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(readPacket.messageId, MessageStatus.READ)
|
||||||
|
}
|
||||||
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
|
ProtocolManager.addLog("✓✓ Read: ${readPacket.messageId.take(8)}...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +142,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
showTypingIndicator()
|
showTypingIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🟢 Онлайн статус
|
||||||
|
ProtocolManager.waitPacket(0x05) { packet ->
|
||||||
|
val onlinePacket = packet as PacketOnlineState
|
||||||
|
if (onlinePacket.publicKey == opponentKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_opponentOnline.value = onlinePacket.online == 1
|
||||||
|
_opponentLastSeen.value = onlinePacket.lastSeen
|
||||||
|
ProtocolManager.addLog("🟢 Online status: ${if (onlinePacket.online == 1) "online" else "offline"}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIncomingMessage(packet: PacketMessage) {
|
private fun handleIncomingMessage(packet: PacketMessage) {
|
||||||
@@ -186,6 +222,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить статус сообщения в БД
|
||||||
|
*/
|
||||||
|
private suspend fun updateMessageStatusInDb(messageId: String, delivered: Int) {
|
||||||
|
val account = myPublicKey ?: return
|
||||||
|
try {
|
||||||
|
messageDao.updateDeliveryStatus(account, messageId, delivered)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Update delivery status error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установить ключи пользователя
|
* Установить ключи пользователя
|
||||||
*/
|
*/
|
||||||
@@ -213,12 +261,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// Сбрасываем состояние
|
// Сбрасываем состояние
|
||||||
_messages.value = emptyList()
|
_messages.value = emptyList()
|
||||||
|
_opponentOnline.value = false
|
||||||
|
_opponentTyping.value = false
|
||||||
currentOffset = 0
|
currentOffset = 0
|
||||||
hasMoreMessages = true
|
hasMoreMessages = true
|
||||||
isLoadingMessages = false
|
isLoadingMessages = false
|
||||||
|
sentReadReceipts.clear()
|
||||||
|
|
||||||
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
|
ProtocolManager.addLog("💬 Dialog opened: ${title.ifEmpty { publicKey.take(16) }}...")
|
||||||
|
|
||||||
|
// Подписываемся на онлайн статус
|
||||||
|
subscribeToOnlineStatus()
|
||||||
|
|
||||||
// Загружаем сообщения из БД
|
// Загружаем сообщения из БД
|
||||||
loadMessagesFromDatabase()
|
loadMessagesFromDatabase()
|
||||||
}
|
}
|
||||||
@@ -444,7 +498,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
encryptedContent = encryptedContent,
|
encryptedContent = encryptedContent,
|
||||||
encryptedKey = encryptedKey,
|
encryptedKey = encryptedKey,
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
isFromMe = true
|
isFromMe = true,
|
||||||
|
delivered = 1 // SENT - сервер принял
|
||||||
)
|
)
|
||||||
|
|
||||||
saveDialog(text, timestamp)
|
saveDialog(text, timestamp)
|
||||||
@@ -543,10 +598,124 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 📝 Отправить индикатор "печатает..."
|
||||||
|
* С throttling чтобы не спамить сервер
|
||||||
|
*/
|
||||||
|
fun sendTypingIndicator() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastTypingSentTime < TYPING_THROTTLE_MS) return
|
||||||
|
|
||||||
|
val opponent = opponentKey ?: return
|
||||||
|
val sender = myPublicKey ?: return
|
||||||
|
val privateKey = myPrivateKey ?: return
|
||||||
|
|
||||||
|
lastTypingSentTime = now
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val packet = PacketTyping().apply {
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = opponent
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
ProtocolManager.addLog("⌨️ Typing indicator sent")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Typing send error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 👁️ Отправить подтверждение о прочтении сообщения
|
||||||
|
*/
|
||||||
|
fun sendReadReceipt(messageId: String, senderPublicKey: String) {
|
||||||
|
// Не отправляем повторно
|
||||||
|
if (sentReadReceipts.contains(messageId)) return
|
||||||
|
|
||||||
|
val sender = myPublicKey ?: return
|
||||||
|
val privateKey = myPrivateKey ?: return
|
||||||
|
|
||||||
|
sentReadReceipts.add(messageId)
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
|
|
||||||
|
val packet = PacketRead().apply {
|
||||||
|
this.messageId = messageId
|
||||||
|
fromPublicKey = sender
|
||||||
|
toPublicKey = senderPublicKey
|
||||||
|
this.privateKey = privateKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
ProtocolManager.addLog("👁️ Read receipt sent for: ${messageId.take(8)}...")
|
||||||
|
|
||||||
|
// Обновляем в БД что сообщение прочитано
|
||||||
|
updateMessageReadInDb(messageId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Read receipt send error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 👁️ Отметить все непрочитанные входящие сообщения как прочитанные
|
||||||
|
*/
|
||||||
|
fun markVisibleMessagesAsRead() {
|
||||||
|
val opponent = opponentKey ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_messages.value
|
||||||
|
.filter { !it.isOutgoing && it.status != MessageStatus.READ }
|
||||||
|
.forEach { message ->
|
||||||
|
sendReadReceipt(message.id, opponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🟢 Подписаться на онлайн статус собеседника
|
||||||
|
*/
|
||||||
|
fun subscribeToOnlineStatus() {
|
||||||
|
val opponent = opponentKey ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val packet = PacketOnlineSubscribe().apply {
|
||||||
|
publicKey = opponent
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.send(packet)
|
||||||
|
ProtocolManager.addLog("🟢 Subscribed to online status: ${opponent.take(16)}...")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Online subscribe error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить статус прочтения в БД
|
||||||
|
*/
|
||||||
|
private suspend fun updateMessageReadInDb(messageId: String) {
|
||||||
|
try {
|
||||||
|
val account = myPublicKey ?: return
|
||||||
|
messageDao.markAsRead(account, messageId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Update read status error", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
|
fun canSend(): Boolean = _inputText.value.isNotBlank() && !isSending
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
|
sentReadReceipts.clear()
|
||||||
opponentKey = null
|
opponentKey = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user