Исправлены ложные галочки и синхронизация статусов сообщений
This commit is contained in:
@@ -159,21 +159,68 @@ private data class IncomingRunAvatarUiState(
|
||||
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 {
|
||||
val directText = message.text.trim()
|
||||
val directText = sanitizeCopyText(message.text, message.senderName)
|
||||
if (directText.isNotEmpty()) {
|
||||
return directText
|
||||
}
|
||||
|
||||
val forwardedText =
|
||||
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")
|
||||
if (forwardedText.isNotEmpty()) {
|
||||
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()) {
|
||||
return replyText
|
||||
}
|
||||
@@ -181,6 +228,48 @@ private fun extractCopyableMessageText(message: ChatMessage): String {
|
||||
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(
|
||||
ExperimentalMaterial3Api::class,
|
||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||
@@ -636,6 +725,7 @@ fun ChatDetailScreen(
|
||||
// If typing, the user is obviously online — never show "offline" while typing
|
||||
val isOnline = rawIsOnline || isTyping
|
||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||
val groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
|
||||
val showMessageSkeleton by
|
||||
produceState(initialValue = false, key1 = isLoading) {
|
||||
if (!isLoading) {
|
||||
@@ -2402,6 +2492,13 @@ fun ChatDetailScreen(
|
||||
label = "listBottomPadding"
|
||||
)
|
||||
when {
|
||||
groupRequiresRejoin -> {
|
||||
GroupRejoinRequiredState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
hasChatWallpaper = hasChatWallpaper,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
// 🔥 СКЕЛЕТОН - показываем пока загружаются
|
||||
// сообщения
|
||||
showMessageSkeleton -> {
|
||||
|
||||
@@ -166,6 +166,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _isLoadingMore = MutableStateFlow(false)
|
||||
val isLoadingMore: StateFlow<Boolean> = _isLoadingMore.asStateFlow()
|
||||
|
||||
private val _groupRequiresRejoin = MutableStateFlow(false)
|
||||
val groupRequiresRejoin: StateFlow<Boolean> = _groupRequiresRejoin.asStateFlow()
|
||||
|
||||
private val _opponentTyping = MutableStateFlow(false)
|
||||
val opponentTyping: StateFlow<Boolean> = _opponentTyping.asStateFlow()
|
||||
private var typingTimeoutJob: kotlinx.coroutines.Job? = null
|
||||
@@ -448,10 +451,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
val nextStatus =
|
||||
when (msg.status) {
|
||||
// Read event can promote only already-sent/delivered messages.
|
||||
MessageStatus.SENT,
|
||||
// Read event can promote only messages that were already delivery-confirmed.
|
||||
MessageStatus.DELIVERED,
|
||||
MessageStatus.READ -> MessageStatus.READ
|
||||
MessageStatus.SENT,
|
||||
MessageStatus.SENDING,
|
||||
MessageStatus.ERROR -> msg.status
|
||||
}
|
||||
@@ -609,6 +612,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (!isGroupDialogKey(publicKey)) {
|
||||
groupKeyCache.remove(publicKey)
|
||||
}
|
||||
_groupRequiresRejoin.value = false
|
||||
groupSenderNameCache.clear()
|
||||
groupSenderResolveRequested.clear()
|
||||
|
||||
@@ -735,6 +739,25 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
loadingJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
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)]
|
||||
|
||||
@@ -2574,9 +2597,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// 3. 🎯 UI обновление в Main потоке
|
||||
// Для обычных диалогов статус остаётся SENDING до PacketDelivery(messageId).
|
||||
withContext(Dispatchers.Main) {
|
||||
// 📁 Для Saved Messages - сразу SENT, для обычных - ждём delivery
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 💾 Сохранение в БД с attachments
|
||||
@@ -2923,13 +2948,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
updateMessageStatusAndAttachmentsInDb(
|
||||
messageId = messageId,
|
||||
delivered = 1,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = finalAttachmentsJson
|
||||
)
|
||||
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
refreshTargetDialog()
|
||||
@@ -3275,19 +3302,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем)
|
||||
val finalAttachmentsJson = attachmentsJson // Уже без localUri
|
||||
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
||||
} else {
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, 1, finalAttachmentsJson)
|
||||
}
|
||||
val deliveryStatus = if (isSavedMessages) 1 else 0
|
||||
updateMessageStatusAndAttachmentsInDb(messageId, deliveryStatus, finalAttachmentsJson)
|
||||
logPhotoPipeline(messageId, "db status+attachments updated")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
// Также очищаем localUri в UI
|
||||
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(
|
||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||
@@ -3468,13 +3498,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
|
||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
|
||||
saveDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||
timestamp = timestamp,
|
||||
@@ -3739,7 +3769,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
updateMessageStatusAndAttachmentsInDb(
|
||||
messageId = messageId,
|
||||
delivered = 1,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = finalDbAttachments.toString()
|
||||
)
|
||||
|
||||
@@ -3748,7 +3778,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_messages.value.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
msg.copy(
|
||||
status = MessageStatus.SENT,
|
||||
status =
|
||||
if (isSavedMessages) MessageStatus.SENT
|
||||
else MessageStatus.SENDING,
|
||||
attachments =
|
||||
msg.attachments.map { current ->
|
||||
val final = finalAttachmentsById[current.id]
|
||||
@@ -3974,14 +4006,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
|
||||
// 🔥 Обновляем статус в БД после отправки
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем UI
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
|
||||
saveDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
||||
timestamp = timestamp,
|
||||
@@ -4164,13 +4195,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
// 🔥 После успешной отправки обновляем статус на DELIVERED (1) в БД и UI
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
// Для обычных чатов статус подтверждаем только по PacketDelivery(messageId).
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
|
||||
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||
@@ -4394,14 +4425,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
attachmentsJson = attachmentsJson
|
||||
)
|
||||
|
||||
// 🔥 Обновляем статус в БД после отправки
|
||||
if (!isSavedMessages) {
|
||||
updateMessageStatusInDb(messageId, 1) // DELIVERED
|
||||
// Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId).
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем UI
|
||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
|
||||
|
||||
saveDialog("\$a=Avatar", timestamp)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@@ -2465,6 +2465,8 @@ fun ChatsListScreen(
|
||||
dialog.opponentKey,
|
||||
isSelected =
|
||||
isSelectedDialog,
|
||||
syncInProgress =
|
||||
syncInProgress,
|
||||
onSwipeStarted = {
|
||||
swipedItemKey =
|
||||
dialog.opponentKey
|
||||
@@ -3501,6 +3503,7 @@ fun SwipeableDialogItem(
|
||||
isDrawerOpen: Boolean = false,
|
||||
isSwipedOpen: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
syncInProgress: Boolean = false,
|
||||
onSwipeStarted: () -> Unit = {},
|
||||
onSwipeClosed: () -> Unit = {},
|
||||
onClick: () -> Unit,
|
||||
@@ -3901,6 +3904,7 @@ fun SwipeableDialogItem(
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
syncInProgress = syncInProgress,
|
||||
avatarRepository = avatarRepository,
|
||||
onClick = null // Tap handled by parent pointerInput
|
||||
)
|
||||
@@ -3917,6 +3921,7 @@ fun DialogItemContent(
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
syncInProgress: Boolean = false,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
@@ -4148,8 +4153,8 @@ fun DialogItemContent(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
// <20> Скрываем статус доставки когда есть черновик (как в Telegram)
|
||||
if (dialog.draftText.isNullOrEmpty()) {
|
||||
// Скрываем статус доставки при черновике и во время синхронизации.
|
||||
if (dialog.draftText.isNullOrEmpty() && !syncInProgress) {
|
||||
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
||||
// галочки (прочитано)
|
||||
if (dialog.isSavedMessages) {
|
||||
@@ -4284,7 +4289,7 @@ fun DialogItemContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика
|
||||
} // 📝 end if (draftText.isNullOrEmpty && !syncInProgress)
|
||||
|
||||
val formattedTime =
|
||||
remember(dialog.lastMessageTimestamp) {
|
||||
|
||||
Reference in New Issue
Block a user