Исправлены ложные галочки и синхронизация статусов сообщений
Some checks failed
Android Kernel Build / build (push) Failing after 44m56s
Some checks failed
Android Kernel Build / build (push) Failing after 44m56s
This commit is contained in:
@@ -806,6 +806,15 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
packet.chachaKey
|
packet.chachaKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isSelfDialog = packet.toPublicKey.trim() == account
|
||||||
|
// Для исходящих сообщений статус доставки меняется ТОЛЬКО по PacketDelivery.
|
||||||
|
val initialDeliveredStatus =
|
||||||
|
if (isOwnMessage && !isSelfDialog) {
|
||||||
|
DeliveryStatus.WAITING.value
|
||||||
|
} else {
|
||||||
|
DeliveryStatus.DELIVERED.value
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем entity для кэша и возможной вставки
|
// Создаем entity для кэша и возможной вставки
|
||||||
val entity =
|
val entity =
|
||||||
MessageEntity(
|
MessageEntity(
|
||||||
@@ -817,7 +826,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
chachaKey = storedChachaKey,
|
chachaKey = storedChachaKey,
|
||||||
read = 0,
|
read = 0,
|
||||||
fromMe = if (isOwnMessage) 1 else 0,
|
fromMe = if (isOwnMessage) 1 else 0,
|
||||||
delivered = DeliveryStatus.DELIVERED.value,
|
delivered = initialDeliveredStatus,
|
||||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||||
attachments = attachmentsJson,
|
attachments = attachmentsJson,
|
||||||
@@ -946,18 +955,34 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
|
// Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
|
||||||
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
|
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
|
||||||
val dialogKey = getDialogKey(toPublicKey)
|
val dialogKey = getDialogKey(toPublicKey)
|
||||||
messageDao.markAllAsRead(account, toPublicKey)
|
val updatedRows = messageDao.markAllAsRead(account, toPublicKey)
|
||||||
|
|
||||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
val readCount =
|
||||||
|
messageCache[dialogKey]?.value?.count {
|
||||||
|
it.isFromMe &&
|
||||||
|
!it.isRead &&
|
||||||
|
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
it.deliveryStatus == DeliveryStatus.READ)
|
||||||
|
} ?: 0
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value =
|
flow.value =
|
||||||
flow.value.map { msg ->
|
flow.value.map { msg ->
|
||||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
|
if (msg.isFromMe &&
|
||||||
|
!msg.isRead &&
|
||||||
|
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
msg.deliveryStatus == DeliveryStatus.READ)
|
||||||
|
) {
|
||||||
|
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
if (updatedRows > 0) {
|
||||||
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = readCount)
|
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||||
|
}
|
||||||
|
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = minOf(readCount, updatedRows))
|
||||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -981,20 +1006,36 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Opponent read our outgoing messages.
|
// Opponent read our outgoing messages.
|
||||||
messageDao.markAllAsRead(account, opponentKey)
|
val updatedRows = messageDao.markAllAsRead(account, opponentKey)
|
||||||
|
|
||||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
val readCount =
|
||||||
|
messageCache[dialogKey]?.value?.count {
|
||||||
|
it.isFromMe &&
|
||||||
|
!it.isRead &&
|
||||||
|
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
it.deliveryStatus == DeliveryStatus.READ)
|
||||||
|
} ?: 0
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value =
|
flow.value =
|
||||||
flow.value.map { msg ->
|
flow.value.map { msg ->
|
||||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
|
if (msg.isFromMe &&
|
||||||
|
!msg.isRead &&
|
||||||
|
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
msg.deliveryStatus == DeliveryStatus.READ)
|
||||||
|
) {
|
||||||
|
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify current dialog UI: all outgoing messages are now read.
|
// Notify current dialog UI only when there are real DB read updates.
|
||||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
if (updatedRows > 0) {
|
||||||
|
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||||
|
}
|
||||||
|
|
||||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = minOf(readCount, updatedRows))
|
||||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -400,10 +400,13 @@ interface MessageDao {
|
|||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
UPDATE messages SET read = 1
|
UPDATE messages SET read = 1
|
||||||
WHERE account = :account AND to_public_key = :opponent AND from_me = 1
|
WHERE account = :account
|
||||||
|
AND to_public_key = :opponent
|
||||||
|
AND from_me = 1
|
||||||
|
AND delivered IN (1, 3)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun markAllAsRead(account: String, opponent: String)
|
suspend fun markAllAsRead(account: String, opponent: String): Int
|
||||||
|
|
||||||
/** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */
|
/** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */
|
||||||
@Query(
|
@Query(
|
||||||
|
|||||||
@@ -159,21 +159,68 @@ private data class IncomingRunAvatarUiState(
|
|||||||
val overlays: List<IncomingRunAvatarOverlay>
|
val overlays: List<IncomingRunAvatarOverlay>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val copiedTimeRegex = Regex("^\\d{1,2}:\\d{2}(?:\\s?[AaPp][Mm])?$")
|
||||||
|
|
||||||
|
private fun sanitizeCopyText(rawText: String, senderName: String): String {
|
||||||
|
if (rawText.isBlank()) return ""
|
||||||
|
|
||||||
|
val normalizedSender = senderName.trim()
|
||||||
|
val lines =
|
||||||
|
rawText
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.split('\n')
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.toMutableList()
|
||||||
|
|
||||||
|
while (lines.isNotEmpty()) {
|
||||||
|
val first = lines.first()
|
||||||
|
val isSenderLine =
|
||||||
|
normalizedSender.isNotBlank() &&
|
||||||
|
first.equals(normalizedSender, ignoreCase = true)
|
||||||
|
val isForwardHeader = first.startsWith("Forwarded from ", ignoreCase = true)
|
||||||
|
if (!isSenderLine && !isForwardHeader) break
|
||||||
|
lines.removeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (lines.isNotEmpty() && copiedTimeRegex.matches(lines.last())) {
|
||||||
|
lines.removeAt(lines.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.joinToString("\n").trim()
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractCopyableMessageText(message: ChatMessage): String {
|
private fun extractCopyableMessageText(message: ChatMessage): String {
|
||||||
val directText = message.text.trim()
|
val directText = sanitizeCopyText(message.text, message.senderName)
|
||||||
if (directText.isNotEmpty()) {
|
if (directText.isNotEmpty()) {
|
||||||
return directText
|
return directText
|
||||||
}
|
}
|
||||||
|
|
||||||
val forwardedText =
|
val forwardedText =
|
||||||
message.forwardedMessages
|
message.forwardedMessages
|
||||||
.mapNotNull { forwarded -> forwarded.text.trim().takeIf { it.isNotEmpty() } }
|
.mapNotNull { forwarded ->
|
||||||
|
sanitizeCopyText(
|
||||||
|
rawText = forwarded.text,
|
||||||
|
senderName =
|
||||||
|
forwarded.forwardedFromName
|
||||||
|
.ifBlank { forwarded.senderName }
|
||||||
|
)
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
.joinToString("\n\n")
|
.joinToString("\n\n")
|
||||||
if (forwardedText.isNotEmpty()) {
|
if (forwardedText.isNotEmpty()) {
|
||||||
return forwardedText
|
return forwardedText
|
||||||
}
|
}
|
||||||
|
|
||||||
val replyText = message.replyData?.text?.trim().orEmpty()
|
val replyText =
|
||||||
|
message.replyData
|
||||||
|
?.let { reply ->
|
||||||
|
sanitizeCopyText(
|
||||||
|
rawText = reply.text,
|
||||||
|
senderName = reply.forwardedFromName.ifBlank { reply.senderName }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.orEmpty()
|
||||||
if (replyText.isNotEmpty()) {
|
if (replyText.isNotEmpty()) {
|
||||||
return replyText
|
return replyText
|
||||||
}
|
}
|
||||||
@@ -181,6 +228,48 @@ private fun extractCopyableMessageText(message: ChatMessage): String {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupRejoinRequiredState(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
hasChatWallpaper: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val titleColor = if (isDarkTheme || hasChatWallpaper) Color.White else Color(0xFF1C1C1E)
|
||||||
|
val subtitleColor = titleColor.copy(alpha = 0.72f)
|
||||||
|
|
||||||
|
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech))
|
||||||
|
val progress by
|
||||||
|
animateLottieCompositionAsState(
|
||||||
|
composition = composition,
|
||||||
|
iterations = LottieConstants.IterateForever
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
LottieAnimation(
|
||||||
|
composition = composition,
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.size(120.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "Join Group Again",
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = titleColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Group sync key is missing. Rejoin this group to load messages.",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = subtitleColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(
|
@OptIn(
|
||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||||
@@ -636,6 +725,7 @@ fun ChatDetailScreen(
|
|||||||
// If typing, the user is obviously online — never show "offline" while typing
|
// If typing, the user is obviously online — never show "offline" while typing
|
||||||
val isOnline = rawIsOnline || isTyping
|
val isOnline = rawIsOnline || isTyping
|
||||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||||
|
val groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
|
||||||
val showMessageSkeleton by
|
val showMessageSkeleton by
|
||||||
produceState(initialValue = false, key1 = isLoading) {
|
produceState(initialValue = false, key1 = isLoading) {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
@@ -2402,6 +2492,13 @@ fun ChatDetailScreen(
|
|||||||
label = "listBottomPadding"
|
label = "listBottomPadding"
|
||||||
)
|
)
|
||||||
when {
|
when {
|
||||||
|
groupRequiresRejoin -> {
|
||||||
|
GroupRejoinRequiredState(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
hasChatWallpaper = hasChatWallpaper,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
||||||
// сообщения
|
// сообщения
|
||||||
showMessageSkeleton -> {
|
showMessageSkeleton -> {
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private val _isLoadingMore = MutableStateFlow(false)
|
private val _isLoadingMore = MutableStateFlow(false)
|
||||||
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||||
|
|
||||||
|
private val _groupRequiresRejoin = MutableStateFlow(false)
|
||||||
|
val groupRequiresRejoin: StateFlow<Boolean> = _groupRequiresRejoin.asStateFlow()
|
||||||
|
|
||||||
private val _opponentTyping = MutableStateFlow(false)
|
private val _opponentTyping = MutableStateFlow(false)
|
||||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||||
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
||||||
@@ -448,10 +451,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
val nextStatus =
|
val nextStatus =
|
||||||
when (msg.status) {
|
when (msg.status) {
|
||||||
// Read event can promote only already-sent/delivered messages.
|
// Read event can promote only messages that were already delivery-confirmed.
|
||||||
MessageStatus.SENT,
|
|
||||||
MessageStatus.DELIVERED,
|
MessageStatus.DELIVERED,
|
||||||
MessageStatus.READ -> MessageStatus.READ
|
MessageStatus.READ -> MessageStatus.READ
|
||||||
|
MessageStatus.SENT,
|
||||||
MessageStatus.SENDING,
|
MessageStatus.SENDING,
|
||||||
MessageStatus.ERROR -> msg.status
|
MessageStatus.ERROR -> msg.status
|
||||||
}
|
}
|
||||||
@@ -609,6 +612,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
if (!isGroupDialogKey(publicKey)) {
|
if (!isGroupDialogKey(publicKey)) {
|
||||||
groupKeyCache.remove(publicKey)
|
groupKeyCache.remove(publicKey)
|
||||||
}
|
}
|
||||||
|
_groupRequiresRejoin.value = false
|
||||||
groupSenderNameCache.clear()
|
groupSenderNameCache.clear()
|
||||||
groupSenderResolveRequested.clear()
|
groupSenderResolveRequested.clear()
|
||||||
|
|
||||||
@@ -735,6 +739,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
loadingJob =
|
loadingJob =
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
if (isGroupDialogKey(opponent)) {
|
||||||
|
val hasGroupKey = resolveGroupKeyForDialog(opponent) != null
|
||||||
|
withContext(Dispatchers.Main.immediate) {
|
||||||
|
_groupRequiresRejoin.value = !hasGroupKey
|
||||||
|
if (!hasGroupKey) {
|
||||||
|
_messages.value = emptyList()
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasGroupKey) {
|
||||||
|
hasMoreMessages = false
|
||||||
|
currentOffset = 0
|
||||||
|
isLoadingMessages = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_groupRequiresRejoin.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
// 🔥 МГНОВЕННАЯ загрузка из кэша если есть!
|
||||||
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
val cachedMessages = dialogMessagesCache[cacheKey(account, dialogKey)]
|
||||||
|
|
||||||
@@ -2574,9 +2597,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 🎯 UI обновление в Main потоке
|
// 3. 🎯 UI обновление в Main потоке
|
||||||
|
// Для обычных диалогов статус остаётся SENDING до PacketDelivery(messageId).
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery
|
if (isSavedMessages) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 💾 Сохранение в БД с attachments
|
// 4. 💾 Сохранение в БД с attachments
|
||||||
@@ -2923,13 +2948,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
updateMessageStatusAndAttachmentsInDb(
|
updateMessageStatusAndAttachmentsInDb(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
delivered = 1,
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
attachmentsJson = finalAttachmentsJson
|
attachmentsJson = finalAttachmentsJson
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isCurrentDialogTarget) {
|
if (isCurrentDialogTarget) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTargetDialog()
|
refreshTargetDialog()
|
||||||
@@ -3275,19 +3302,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем)
|
// (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем)
|
||||||
val finalAttachmentsJson = attachmentsJson // Уже без localUri
|
val finalAttachmentsJson = attachmentsJson // Уже без localUri
|
||||||
|
|
||||||
if (!isSavedMessages) {
|
val deliveryStatus = if (isSavedMessages) 1 else 0
|
||||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
updateMessageStatusAndAttachmentsInDb(messageId, deliveryStatus, finalAttachmentsJson)
|
||||||
} else {
|
|
||||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
|
||||||
}
|
|
||||||
logPhotoPipeline(messageId, "db status+attachments updated")
|
logPhotoPipeline(messageId, "db status+attachments updated")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
// Также очищаем localUri в UI
|
// Также очищаем localUri в UI
|
||||||
updateMessageAttachments(messageId, null)
|
updateMessageAttachments(messageId, null)
|
||||||
}
|
}
|
||||||
logPhotoPipeline(messageId, "ui status switched to SENT")
|
logPhotoPipeline(
|
||||||
|
messageId,
|
||||||
|
if (isSavedMessages) "ui status switched to SENT"
|
||||||
|
else "ui status kept at SENDING until delivery ACK"
|
||||||
|
)
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||||
@@ -3468,13 +3498,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
|
||||||
if (!isSavedMessages) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -3739,7 +3769,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
updateMessageStatusAndAttachmentsInDb(
|
updateMessageStatusAndAttachmentsInDb(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
delivered = 1,
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
attachmentsJson = finalDbAttachments.toString()
|
attachmentsJson = finalDbAttachments.toString()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3748,7 +3778,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_messages.value.map { msg ->
|
_messages.value.map { msg ->
|
||||||
if (msg.id != messageId) return@map msg
|
if (msg.id != messageId) return@map msg
|
||||||
msg.copy(
|
msg.copy(
|
||||||
status = MessageStatus.SENT,
|
status =
|
||||||
|
if (isSavedMessages) MessageStatus.SENT
|
||||||
|
else MessageStatus.SENDING,
|
||||||
attachments =
|
attachments =
|
||||||
msg.attachments.map { current ->
|
msg.attachments.map { current ->
|
||||||
val final = finalAttachmentsById[current.id]
|
val final = finalAttachmentsById[current.id]
|
||||||
@@ -3974,14 +4006,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 Обновляем статус в БД после отправки
|
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
|
||||||
if (!isSavedMessages) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем UI
|
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@@ -4164,13 +4195,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = attachmentsJson
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
// Для обычных чатов статус подтверждаем только по PacketDelivery(messageId).
|
||||||
if (!isSavedMessages) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
|
||||||
|
|
||||||
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
@@ -4394,14 +4425,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
attachmentsJson = attachmentsJson
|
attachmentsJson = attachmentsJson
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 Обновляем статус в БД после отправки
|
// Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId).
|
||||||
if (!isSavedMessages) {
|
withContext(Dispatchers.Main) {
|
||||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
if (isSavedMessages) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем UI
|
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
|
||||||
|
|
||||||
saveDialog("\$a=Avatar", timestamp)
|
saveDialog("\$a=Avatar", timestamp)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -2465,6 +2465,8 @@ fun ChatsListScreen(
|
|||||||
dialog.opponentKey,
|
dialog.opponentKey,
|
||||||
isSelected =
|
isSelected =
|
||||||
isSelectedDialog,
|
isSelectedDialog,
|
||||||
|
syncInProgress =
|
||||||
|
syncInProgress,
|
||||||
onSwipeStarted = {
|
onSwipeStarted = {
|
||||||
swipedItemKey =
|
swipedItemKey =
|
||||||
dialog.opponentKey
|
dialog.opponentKey
|
||||||
@@ -3501,6 +3503,7 @@ fun SwipeableDialogItem(
|
|||||||
isDrawerOpen: Boolean = false,
|
isDrawerOpen: Boolean = false,
|
||||||
isSwipedOpen: Boolean = false,
|
isSwipedOpen: Boolean = false,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
|
syncInProgress: Boolean = false,
|
||||||
onSwipeStarted: () -> Unit = {},
|
onSwipeStarted: () -> Unit = {},
|
||||||
onSwipeClosed: () -> Unit = {},
|
onSwipeClosed: () -> Unit = {},
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@@ -3901,6 +3904,7 @@ fun SwipeableDialogItem(
|
|||||||
isPinned = isPinned,
|
isPinned = isPinned,
|
||||||
isBlocked = isBlocked,
|
isBlocked = isBlocked,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
|
syncInProgress = syncInProgress,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onClick = null // Tap handled by parent pointerInput
|
onClick = null // Tap handled by parent pointerInput
|
||||||
)
|
)
|
||||||
@@ -3917,6 +3921,7 @@ fun DialogItemContent(
|
|||||||
isPinned: Boolean = false,
|
isPinned: Boolean = false,
|
||||||
isBlocked: Boolean = false,
|
isBlocked: Boolean = false,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
|
syncInProgress: Boolean = false,
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
onClick: (() -> Unit)? = null
|
onClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
@@ -4148,8 +4153,8 @@ fun DialogItemContent(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
// <20> Скрываем статус доставки когда есть черновик (как в Telegram)
|
// Скрываем статус доставки при черновике и во время синхронизации.
|
||||||
if (dialog.draftText.isNullOrEmpty()) {
|
if (dialog.draftText.isNullOrEmpty() && !syncInProgress) {
|
||||||
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
||||||
// галочки (прочитано)
|
// галочки (прочитано)
|
||||||
if (dialog.isSavedMessages) {
|
if (dialog.isSavedMessages) {
|
||||||
@@ -4284,7 +4289,7 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика
|
} // 📝 end if (draftText.isNullOrEmpty && !syncInProgress)
|
||||||
|
|
||||||
val formattedTime =
|
val formattedTime =
|
||||||
remember(dialog.lastMessageTimestamp) {
|
remember(dialog.lastMessageTimestamp) {
|
||||||
|
|||||||
@@ -338,8 +338,12 @@ object UpdateManager {
|
|||||||
persistState(ctx)
|
persistState(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
val version = latestUpdateInfo?.version
|
val availableVersion = resolveAvailableUpdateVersion()
|
||||||
_updateState.value = if (version != null) UpdateState.UpdateAvailable(version) else UpdateState.Idle
|
_updateState.value = if (availableVersion != null) {
|
||||||
|
UpdateState.UpdateAvailable(availableVersion)
|
||||||
|
} else {
|
||||||
|
UpdateState.Idle
|
||||||
|
}
|
||||||
_downloadProgress.value = 0
|
_downloadProgress.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +370,7 @@ object UpdateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setUpdateAvailableOrIdleState() {
|
private fun setUpdateAvailableOrIdleState() {
|
||||||
val version = latestUpdateInfo?.version ?: activeApkVersion
|
val version = resolveAvailableUpdateVersion()
|
||||||
_updateState.value = if (!version.isNullOrBlank()) {
|
_updateState.value = if (!version.isNullOrBlank()) {
|
||||||
UpdateState.UpdateAvailable(version)
|
UpdateState.UpdateAvailable(version)
|
||||||
} else {
|
} else {
|
||||||
@@ -374,6 +378,29 @@ object UpdateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveAvailableUpdateVersion(): String? {
|
||||||
|
val latestVersion =
|
||||||
|
latestUpdateInfo
|
||||||
|
?.takeIf {
|
||||||
|
!it.servicePackUrl.isNullOrBlank() &&
|
||||||
|
it.version.isNotBlank() &&
|
||||||
|
compareVersions(it.version, appVersion) > 0
|
||||||
|
}
|
||||||
|
?.version
|
||||||
|
if (!latestVersion.isNullOrBlank()) {
|
||||||
|
return latestVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
val persistedVersion = activeApkVersion
|
||||||
|
return if (!persistedVersion.isNullOrBlank() &&
|
||||||
|
compareVersions(persistedVersion, appVersion) > 0
|
||||||
|
) {
|
||||||
|
persistedVersion
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun restorePersistedState(context: Context) {
|
private fun restorePersistedState(context: Context) {
|
||||||
val preferences = prefs(context)
|
val preferences = prefs(context)
|
||||||
val restoredId = preferences.getLong(KEY_DOWNLOAD_ID, -1L).takeIf { it >= 0L }
|
val restoredId = preferences.getLong(KEY_DOWNLOAD_ID, -1L).takeIf { it >= 0L }
|
||||||
@@ -529,12 +556,17 @@ object UpdateManager {
|
|||||||
if (items == null || items.length() == 0) {
|
if (items == null || items.length() == 0) {
|
||||||
sduLog("No items in response, trying legacy format...")
|
sduLog("No items in response, trying legacy format...")
|
||||||
// Fallback на старый формат /updates/get
|
// Fallback на старый формат /updates/get
|
||||||
|
val version = obj.optString("version", "")
|
||||||
|
val rawServicePackUrl =
|
||||||
|
obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" }
|
||||||
|
val hasNewerVersion =
|
||||||
|
version.isNotBlank() && compareVersions(version, appVersion) > 0
|
||||||
return UpdateInfo(
|
return UpdateInfo(
|
||||||
version = obj.optString("version", ""),
|
version = version,
|
||||||
platform = obj.optString("platform", ""),
|
platform = obj.optString("platform", ""),
|
||||||
arch = obj.optString("arch", ""),
|
arch = obj.optString("arch", ""),
|
||||||
kernelUpdateRequired = obj.optBoolean("kernel_update_required", false),
|
kernelUpdateRequired = obj.optBoolean("kernel_update_required", false),
|
||||||
servicePackUrl = obj.optString("service_pack_url", "").takeIf { it.isNotEmpty() && it != "null" },
|
servicePackUrl = rawServicePackUrl?.takeIf { hasNewerVersion },
|
||||||
kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" }
|
kernelUrl = obj.optString("kernel_url", "").takeIf { it.isNotEmpty() && it != "null" }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user