feat: harden chat list sync and polish media viewer
Some checks failed
Android CI / android (push) Failing after 4m7s
Android Release / release (push) Failing after 4m9s
CI / test (push) Failing after 2m20s

- prevent stale refreshChat snapshots from overwriting newer realtime chat list state

- add reaction overlay and double-tap zoom in the media viewer
This commit is contained in:
2026-04-06 01:13:19 +03:00
parent fbe4bda9ef
commit cde92eb28b
3 changed files with 83 additions and 3 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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<ru.daemonlord.messenger.domain.message.model.MessageReaction> = 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<MessageItem>): List<ChatViewerMediaItem> {
private fun buildChatViewerMediaItems(
messages: List<MessageItem>,
reactionByMessageId: Map<Long, List<ru.daemonlord.messenger.domain.message.model.MessageReaction>> = emptyMap(),
): List<ChatViewerMediaItem> {
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<MessageItem>): List<ChatVie
!fileType.contains("webp") -> ChatViewerMediaItem(
url = attachment.fileUrl,
type = ChatViewerMediaType.Image,
reactions = reactions,
)
fileType.startsWith("video/") &&
@@ -6531,6 +6582,7 @@ private fun buildChatViewerMediaItems(messages: List<MessageItem>): List<ChatVie
!messageType.contains("video_note") -> ChatViewerMediaItem(
url = attachment.fileUrl,
type = ChatViewerMediaType.Video,
reactions = reactions,
)
else -> null
@@ -6549,11 +6601,13 @@ private fun buildChatViewerMediaItems(messages: List<MessageItem>): List<ChatVie
!isStickerLikeUrl(legacyUrl) -> items += ChatViewerMediaItem(
url = legacyUrl,
type = ChatViewerMediaType.Image,
reactions = reactions,
)
messageType == "video" -> items += ChatViewerMediaItem(
url = legacyUrl,
type = ChatViewerMediaType.Video,
reactions = reactions,
)
}
}