diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 9c90ac1..829e6cb 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 2b230d1..7e6e39b 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 96249d1..7d94a58 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index fbb5f21..ee68a6b 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 1f63230..ca66158 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 7dc01d9..9f4c4ac 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index e0faab7..3e66bf0 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/data/paging/MessageRemoteMediator.kt b/client-mobile/chats/data/paging/MessageRemoteMediator.kt index ba538ca..a460c4e 100644 --- a/client-mobile/chats/data/paging/MessageRemoteMediator.kt +++ b/client-mobile/chats/data/paging/MessageRemoteMediator.kt @@ -178,7 +178,13 @@ private fun MessageDto.toEntity( val reactionsJson = gson.toJson(reactionsMap) // Определяем, прочитано ли сообщение текущим пользователем - val isRead = senderId == currentUserId + // Для своих сообщений: проверяем, прочитал ли кто-то другой (получатели) + // Для чужих сообщений: проверяем, прочитал ли текущий пользователь + val isRead = if (senderId == currentUserId) { + readBy?.any { it.userId != currentUserId } ?: false + } else { + readBy?.any { it.userId == currentUserId } ?: false + } return MessageEntity( id = id, diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index 9dcb104..0e4174a 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -23,7 +23,12 @@ data class MessageDto( @SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null, @SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false, @SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null, - @SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null + @SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null, + @SerializedName("readBy", alternate = ["ReadBy"]) val readBy: List? = emptyList() +) + +data class ReadByDto( + @SerializedName("userId") val userId: String ) data class ReactionDto( diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 529d1de..970fa50 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -153,7 +153,8 @@ class ChatRepositoryImpl @Inject constructor( Log.d(TAG, "Cached ${entities.size} messages") } - messages.map { msg -> msg.toDomain(currentUserId, baseUrl).copy(isRead = true) } + // Используем корректную логику из маппера (readBy), а не принудительно true + messages.map { msg -> msg.toDomain(currentUserId, baseUrl) } } catch (e: Exception) { Log.e(TAG, "Fetch messages failed", e) emptyList() @@ -186,7 +187,7 @@ class ChatRepositoryImpl @Inject constructor( mediaType = type.uppercase(), mediaJson = "[]", reactionsJson = "{}", - isRead = true, replyToId = replyToId, + isRead = false, replyToId = replyToId, syncStatus = SyncStatus.SYNCING, isDeletedLocally = false, isEditedLocally = false, editedContent = null, lastUpdated = currentTime @@ -243,7 +244,7 @@ class ChatRepositoryImpl @Inject constructor( else -> MediaType.TEXT }, reactions = emptyMap(), - isRead = true, + isRead = false, isPinned = false, isForwarded = false, forwardedFromName = null, diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt index 5a4ce62..810a737 100644 --- a/client-mobile/chats/data/repository/Mappers.kt +++ b/client-mobile/chats/data/repository/Mappers.kt @@ -97,6 +97,17 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message { } } + // Исправленная логика isRead: + // Для своих сообщений: проверяем, прочитал ли кто-то другой (получатели) + // Для чужих сообщений: проверяем, прочитал ли текущий пользователь + val isRead = if (senderId == currentUserId) { + // Своё сообщение: прочитано, если кто-то кроме отправителя в readBy + readBy?.any { it.userId != currentUserId } ?: false + } else { + // Чужое сообщение: прочитано, если текущий пользователь в readBy + readBy?.any { it.userId == currentUserId } ?: false + } + return Message( id = id, chatId = chatId ?: "", @@ -118,7 +129,7 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message { }, mediaType = domainMediaType, reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), - isRead = senderId == currentUserId, + isRead = isRead, isPinned = isPinned ?: false, isForwarded = forwardedFromId != null, forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username, @@ -127,6 +138,13 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message { } fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity { + // Исправленная логика isRead для базы данных + val isRead = if (senderId == currentUserId) { + readBy?.any { it.userId != currentUserId } ?: false + } else { + readBy?.any { it.userId == currentUserId } ?: false + } + return core.database.data.MessageEntity( id = id, chatId = chatId ?: "", @@ -139,7 +157,7 @@ fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google mediaType = type ?: "text", mediaJson = gson.toJson(media), reactionsJson = gson.toJson(reactions), - isRead = senderId == currentUserId, + isRead = isRead, replyToId = replyTo?.id ) } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index eb1d715..0f3ef5c 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -350,10 +350,19 @@ class ChatDetailViewModel @Inject constructor( _state.update { it.copy(isTyping = false) } } is ChatEvent.MessagesRead -> { + val currentUserId = getCurrentUserId() _state.update { currentState -> val updatedMessages = currentState.messages.map { msg -> if (msg.sequenceId <= event.lastReadSequenceId) { - msg.copy(isRead = true) + // Обновляем isRead на основе readBy + val isRead = if (msg.senderId == currentUserId) { + // Своё сообщение: прочитано, если кто-то кроме отправителя в readBy + event.userId != currentUserId + } else { + // Чужое сообщение: прочитано, если текущий пользователь в readBy + event.userId == currentUserId + } + msg.copy(isRead = isRead) } else msg } currentState.copy(messages = updatedMessages) @@ -391,8 +400,9 @@ class ChatDetailViewModel @Inject constructor( val messages = _state.value.messages if (messages.isEmpty()) return - // В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника val currentUserId = getCurrentUserId() + + // Находим последнее сообщение от собеседника val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return viewModelScope.launch { @@ -400,16 +410,21 @@ class ChatDetailViewModel @Inject constructor( // Мгновенно обновляем в памяти для "галочек" _state.update { currentState -> val updatedMessages = currentState.messages.map { msg -> + // Для чужих сообщений помечаем как прочитанные, если sequenceId <= последнего сообщения от собеседника if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) { msg.copy(isRead = true) } else msg } currentState.copy(messages = updatedMessages) } + + // Отправляем на сервер repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId) + + // Обновляем через SignalR observer для получения актуальных данных signalrNotificationObserver.refresh() } catch (e: Exception) { - // Ignore + android.util.Log.e("ChatDetailVM", "markAsRead failed", e) } } } diff --git a/client-mobile/chats/presentation/components/ChatItem.kt b/client-mobile/chats/presentation/components/ChatItem.kt index 96d16b9..e2ac960 100644 --- a/client-mobile/chats/presentation/components/ChatItem.kt +++ b/client-mobile/chats/presentation/components/ChatItem.kt @@ -16,15 +16,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.runtime.remember import chats.domain.model.Chat +import chats.domain.model.Message import chats.domain.model.MediaType import core.presentation.components.AppAvatar import ru.knot.messager.R +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.DoneAll @Composable fun ChatItem( chat: Chat, onClick: (String) -> Unit ) { + val currentUserId = "current_user_id" // TODO: Get from AuthManager/TokenManager + Row( modifier = Modifier .fillMaxWidth() @@ -100,20 +106,44 @@ fun ChatItem( modifier = Modifier.weight(1f) ) - if (chat.unreadCount > 0) { - Box( - modifier = Modifier - .padding(start = 8.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape) - .padding(horizontal = 6.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = chat.unreadCount.toString(), - color = Color.White, - fontSize = 10.sp, - fontWeight = FontWeight.Bold - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + // Галочки прочтения только для своих сообщений + chat.lastMessage?.let { lastMessage -> + if (lastMessage.senderId == currentUserId && !lastMessage.isRead) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.size(14.dp) + ) + } else if (lastMessage.senderId == currentUserId && lastMessage.isRead) { + Icon( + imageVector = Icons.Default.DoneAll, + contentDescription = null, + tint = Color.Blue, + modifier = Modifier.size(14.dp) + ) + } + } + + if (chat.unreadCount > 0) { + Spacer(modifier = Modifier.width(4.dp)) + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, CircleShape) + .padding(horizontal = 6.dp, vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = chat.unreadCount.toString(), + color = Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } } } }