From cde92eb28b64cb1b2d9796c4378444202fc8213c Mon Sep 17 00:00:00 2001 From: benya Date: Mon, 6 Apr 2026 01:13:19 +0300 Subject: [PATCH] feat: harden chat list sync and polish media viewer - prevent stale refreshChat snapshots from overwriting newer realtime chat list state - add reaction overlay and double-tap zoom in the media viewer --- .../messenger/data/chat/local/dao/ChatDao.kt | 3 + .../chat/repository/NetworkChatRepository.kt | 25 +++++++- .../messenger/ui/chat/ChatScreen.kt | 58 ++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt index d77d155..8002817 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/local/dao/ChatDao.kt @@ -89,6 +89,9 @@ interface ChatDao { @Query("SELECT muted FROM chats WHERE id = :chatId LIMIT 1") suspend fun isChatMuted(chatId: Long): Boolean? + @Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1") + suspend fun getChatEntity(chatId: Long): ChatEntity? + @Query("UPDATE chats SET muted = :muted WHERE id = :chatId") suspend fun updateChatMuted(chatId: Long, muted: Boolean) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt index 23c0b15..679eef4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -20,6 +20,7 @@ import ru.daemonlord.messenger.data.chat.dto.ChatNotificationSettingsUpdateDto import ru.daemonlord.messenger.data.chat.dto.ChatProfileUpdateRequestDto import ru.daemonlord.messenger.data.chat.dto.ChatTitleUpdateRequestDto import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto +import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.mapper.toChatEntity import ru.daemonlord.messenger.data.chat.mapper.toDomain @@ -82,7 +83,21 @@ class NetworkChatRepository @Inject constructor( try { val chat = chatApiService.getChatById(chatId = chatId) chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty()) - chatDao.upsertChats(listOf(chat.toChatEntity())) + val incomingEntity = chat.toChatEntity() + val localEntity = chatDao.getChatEntity(chatId = chatId) + val mergedEntity = if (localEntity != null && localEntity.isLocallyNewerThan(incomingEntity)) { + incomingEntity.copy( + unreadCount = maxOf(localEntity.unreadCount, incomingEntity.unreadCount), + unreadMentionsCount = maxOf(localEntity.unreadMentionsCount, incomingEntity.unreadMentionsCount), + lastMessageText = localEntity.lastMessageText, + lastMessageType = localEntity.lastMessageType, + lastMessageCreatedAt = localEntity.lastMessageCreatedAt, + updatedSortAt = localEntity.updatedSortAt, + ) + } else { + incomingEntity + } + chatDao.upsertChats(listOf(mergedEntity)) AppResult.Success(Unit) } catch (error: Throwable) { AppResult.Error(error.toAppError()) @@ -422,3 +437,11 @@ class NetworkChatRepository @Inject constructor( ) } } + +private fun ChatEntity.isLocallyNewerThan(other: ChatEntity): Boolean { + val localSort = updatedSortAt ?: lastMessageCreatedAt + val remoteSort = other.updatedSortAt ?: other.lastMessageCreatedAt + if (localSort.isNullOrBlank()) return false + if (remoteSort.isNullOrBlank()) return true + return localSort > remoteSort +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index d7f4edd..a1c0fd9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -656,7 +656,12 @@ private fun ChatScreen( val view = LocalView.current val listState = rememberLazyListState() val scope = rememberCoroutineScope() - val allViewerMediaItems = remember(state.messages) { buildChatViewerMediaItems(state.messages) } + val allViewerMediaItems = remember(state.messages, state.reactionByMessageId) { + buildChatViewerMediaItems( + messages = state.messages, + reactionByMessageId = state.reactionByMessageId, + ) + } val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isChannelChat = state.chatType.equals("channel", ignoreCase = true) val isPrivateChat = state.chatType.equals("private", ignoreCase = true) @@ -2451,6 +2456,7 @@ private fun PrivateChatInfoCard( private data class ChatViewerMediaItem( val url: String, val type: ChatViewerMediaType, + val reactions: List = emptyList(), ) @Composable @@ -2518,6 +2524,37 @@ private fun ChatMediaViewerOverlay( ) } } + + AnimatedVisibility( + visible = showTopBar && items.getOrNull(pagerState.currentPage)?.reactions?.isNotEmpty() == true, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items.getOrNull(pagerState.currentPage) + ?.reactions + ?.forEach { reaction -> + Surface( + shape = RoundedCornerShape(999.dp), + color = Color.White.copy(alpha = 0.14f), + ) { + Text( + text = "${reaction.emoji} ${reaction.count}", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (reaction.reacted) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } } } @@ -2588,6 +2625,15 @@ private fun ZoomableImageViewerPage( .pointerInput(imageUrl) { detectTapGestures( onTap = { onToggleChrome() }, + onDoubleTap = { + if (scale > 1.3f) { + scale = 1f + offset = Offset.Zero + } else { + scale = 2.2f + offset = Offset.Zero + } + }, ) }, contentAlignment = Alignment.Center, @@ -6512,10 +6558,14 @@ private fun buildChatInfoEntries( return entries } -private fun buildChatViewerMediaItems(messages: List): List { +private fun buildChatViewerMediaItems( + messages: List, + reactionByMessageId: Map> = emptyMap(), +): List { return messages .flatMap { message -> val messageType = message.type.lowercase(Locale.getDefault()) + val reactions = reactionByMessageId[message.id].orEmpty() val items = message.attachments.mapNotNull { attachment -> val fileType = attachment.fileType.lowercase(Locale.getDefault()) when { @@ -6524,6 +6574,7 @@ private fun buildChatViewerMediaItems(messages: List): List ChatViewerMediaItem( url = attachment.fileUrl, type = ChatViewerMediaType.Image, + reactions = reactions, ) fileType.startsWith("video/") && @@ -6531,6 +6582,7 @@ private fun buildChatViewerMediaItems(messages: List): List ChatViewerMediaItem( url = attachment.fileUrl, type = ChatViewerMediaType.Video, + reactions = reactions, ) else -> null @@ -6549,11 +6601,13 @@ private fun buildChatViewerMediaItems(messages: List): List items += ChatViewerMediaItem( url = legacyUrl, type = ChatViewerMediaType.Image, + reactions = reactions, ) messageType == "video" -> items += ChatViewerMediaItem( url = legacyUrl, type = ChatViewerMediaType.Video, + reactions = reactions, ) } }