diff --git a/client-mobile/chats/data/local/database/AppDatabase.kt b/client-mobile/chats/data/local/database/AppDatabase.kt index e06a88d..346fec5 100644 --- a/client-mobile/chats/data/local/database/AppDatabase.kt +++ b/client-mobile/chats/data/local/database/AppDatabase.kt @@ -17,7 +17,7 @@ import chats.data.local.dao.UserProfileDao ChatEntity::class, UserProfileEntity::class ], - version = 1, + version = 2, exportSchema = false ) @TypeConverters(MessageStatusConverter::class) diff --git a/client-mobile/chats/data/local/database/ChatEntity.kt b/client-mobile/chats/data/local/database/ChatEntity.kt index e459c67..250d5a8 100644 --- a/client-mobile/chats/data/local/database/ChatEntity.kt +++ b/client-mobile/chats/data/local/database/ChatEntity.kt @@ -33,5 +33,7 @@ data class ChatEntity( val lastMessageTimestamp: Long? = null, + val lastMessageJson: String? = null, + val updatedAtMillis: Long = System.currentTimeMillis() ) diff --git a/client-mobile/chats/data/local/mappers/ChatMappers.kt b/client-mobile/chats/data/local/mappers/ChatMappers.kt index 28720ad..ca2bb34 100644 --- a/client-mobile/chats/data/local/mappers/ChatMappers.kt +++ b/client-mobile/chats/data/local/mappers/ChatMappers.kt @@ -2,6 +2,9 @@ package chats.data.local.mappers import chats.data.local.database.ChatEntity import chats.domain.model.Chat +import com.google.gson.Gson + +private val gson = Gson() /** * Преобразует Domain Chat в Entity @@ -16,7 +19,8 @@ fun Chat.toEntity(): ChatEntity { avatar = this.avatar, unreadCount = this.unreadCount, lastMessageText = this.lastMessage?.content, - lastMessageTimestamp = timestamp + lastMessageTimestamp = timestamp, + lastMessageJson = this.lastMessage?.let { gson.toJson(it) } ) } @@ -24,13 +28,20 @@ fun Chat.toEntity(): ChatEntity { * Преобразует Entity в Domain Chat */ fun ChatEntity.toDomain(): Chat { + val lastMessage = this.lastMessageJson?.let { json -> + try { + gson.fromJson(json, chats.domain.model.Message::class.java) + } catch (e: Exception) { + null + } + } return Chat( id = this.remoteId ?: this.localId, type = this.type, name = this.name, avatar = this.avatar, unreadCount = this.unreadCount, - lastMessage = null // lastMessage загружается отдельно + lastMessage = lastMessage ) } diff --git a/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt b/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt index a2820ef..fdec53f 100644 --- a/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt +++ b/client-mobile/chats/data/local/paging/MessageRemoteMediator.kt @@ -13,6 +13,7 @@ import chats.data.local.mappers.toEntity import chats.data.repository.toDomain as dtoToDomain import chats.data.remote.api.ChatApi import chats.domain.model.MessageStatus +import core.network.ServerConfig import core.security.TokenManager /** @@ -26,7 +27,8 @@ class MessageRemoteMediator( private val chatDao: ChatDao, private val chatApi: ChatApi, private val tokenManager: TokenManager, - private val appDatabase: AppDatabase + private val appDatabase: AppDatabase, + private val serverConfig: ServerConfig ) : RemoteMediator() { companion object { @@ -39,7 +41,7 @@ class MessageRemoteMediator( ): RemoteMediator.MediatorResult { return try { val currentUserId = tokenManager.getUserId() ?: "" - val baseUrl = "https://your-server.com" + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val cursor = when (loadType) { LoadType.REFRESH -> { diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt index 393caaf..140b813 100644 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ b/client-mobile/chats/data/remote/api/ChatApi.kt @@ -59,6 +59,9 @@ interface ChatApi { @POST("messages/{messageId}/reactions") suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String) + @POST("chats/favorites") + suspend fun getOrCreateFavorites(): ChatDto + @POST("chats/personal") suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 39557fe..3612299 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -64,6 +64,7 @@ class ChatRepositoryImpl @Inject constructor( override suspend fun getChats(): List { val currentUserId = tokenManager.getUserId() ?: "" val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + val gson = com.google.gson.Gson() return try { // Пробуем получить с сервера @@ -71,7 +72,12 @@ class ChatRepositoryImpl @Inject constructor( val domainChats = remoteChats.map { it.toDomain(currentUserId, baseUrl) } // Сохраняем в локальную БД - val entities = domainChats.map { it.toEntity() } + val entities = domainChats.map { chat -> + val existing = chatDao.getChatByRemoteId(chat.id) + chat.toEntity().copy( + localId = existing?.localId ?: java.util.UUID.randomUUID().toString() + ) + } chatDao.insertChats(entities) domainChats @@ -87,11 +93,21 @@ class ChatRepositoryImpl @Inject constructor( val currentUserId = tokenManager.getUserId() ?: "" val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val remoteChats = api.getChats() + val gson = com.google.gson.Gson() val entities = remoteChats.map { dto -> val existing = chatDao.getChatByRemoteId(dto.id) + val lastMessage = dto.messages.firstOrNull() + val timestamp = lastMessage?.createdAt?.let { + try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } + catch (e: Exception) { null } + } + val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl) dto.toDomain(currentUserId, baseUrl).toEntity().copy( - localId = existing?.localId ?: java.util.UUID.randomUUID().toString() + localId = existing?.localId ?: java.util.UUID.randomUUID().toString(), + lastMessageText = lastMessage?.content, + lastMessageTimestamp = timestamp, + lastMessageJson = lastMessageDomain?.let { gson.toJson(it) } ) } chatDao.insertChats(entities) @@ -130,7 +146,8 @@ class ChatRepositoryImpl @Inject constructor( chatDao = chatDao, chatApi = api, tokenManager = tokenManager, - appDatabase = appDatabase + appDatabase = appDatabase, + serverConfig = serverConfig ) ).flow.map { pagingData -> pagingData.map { entity -> entity.toDomain() } @@ -266,6 +283,7 @@ class ChatRepositoryImpl @Inject constructor( // ==================== Вспомогательные методы ==================== private suspend fun saveMessagesToLocal(messages: List, chatId: String, currentUserId: String) { + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") messages.forEach { dto -> val existing = messageDao.getMessageByServerId(dto.id) if (existing == null) { @@ -276,19 +294,19 @@ class ChatRepositoryImpl @Inject constructor( chatId = chatId, senderId = dto.senderId ?: dto.sender?.id ?: currentUserId, senderName = dto.sender?.displayName ?: dto.sender?.username ?: "", - senderAvatar = dto.sender?.avatarUrl, + senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" }, content = dto.content, sequenceId = dto.sequenceId?.toLong() ?: 0L, createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(), mediaType = dto.type ?: "TEXT", - mediaJson = gson.toJson(dto.media.map { + mediaJson = gson.toJson(dto.media.map { mediaItem -> chats.domain.model.Media( - id = it.id, - type = it.type, - url = it.url, - filename = it.filename, - size = it.size, - duration = it.duration + id = mediaItem.id, + type = mediaItem.type, + url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" }, + filename = mediaItem.filename, + size = mediaItem.size, + duration = mediaItem.duration ) }), reactionsJson = gson.toJson( @@ -322,6 +340,10 @@ class ChatRepositoryImpl @Inject constructor( } } + override suspend fun resetUnreadCount(chatId: String) { + chatDao.updateUnreadCount(chatId, 0) + } + override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) { try { hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId)) @@ -356,6 +378,15 @@ class ChatRepositoryImpl @Inject constructor( return api.getGifCategories().data.categories } + override suspend fun getOrCreateFavorites(): Chat { + val currentUserId = tokenManager.getUserId() ?: "" + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + val dto = api.getOrCreateFavorites() + val chat = dto.toDomain(currentUserId, baseUrl) + chatDao.insertChat(chat.toEntity()) + return chat + } + override suspend fun createPersonalChat(userId: String): Chat { val currentUserId = tokenManager.getUserId() ?: "" val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") diff --git a/client-mobile/chats/data/workers/ChatSyncWorker.kt b/client-mobile/chats/data/workers/ChatSyncWorker.kt index 27161da..035f842 100644 --- a/client-mobile/chats/data/workers/ChatSyncWorker.kt +++ b/client-mobile/chats/data/workers/ChatSyncWorker.kt @@ -6,6 +6,8 @@ import androidx.hilt.work.HiltWorker import androidx.work.* import chats.data.local.dao.ChatDao import chats.data.local.dao.MessageDao +import chats.data.remote.dto.* +import chats.data.repository.toDomain import chats.domain.model.MessageStatus import core.security.TokenManager import dagger.assisted.Assisted @@ -27,7 +29,8 @@ class ChatSyncWorker @AssistedInject constructor( private val chatDao: ChatDao, private val messageDao: MessageDao, private val chatApi: chats.data.remote.api.ChatApi, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, + private val serverConfig: core.network.ServerConfig ) : CoroutineWorker(context, params) { companion object { @@ -104,6 +107,9 @@ class ChatSyncWorker @AssistedInject constructor( private suspend fun syncChats() { val remoteChats = chatApi.getChats() val localChats = mutableListOf() + val currentUserId = tokenManager.getUserId() ?: "" + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + val gson = com.google.gson.Gson() remoteChats.forEach { dto -> val existingChat = chatDao.getChatByRemoteId(dto.id) @@ -112,6 +118,7 @@ class ChatSyncWorker @AssistedInject constructor( try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } catch (e: Exception) { null } } + val lastMessageDomain = lastMessage?.toDomain(currentUserId, baseUrl) val chatEntity = chats.data.local.database.ChatEntity( localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(), remoteId = dto.id, @@ -120,11 +127,40 @@ class ChatSyncWorker @AssistedInject constructor( avatar = dto.avatar, unreadCount = dto.unreadCount, lastMessageText = lastMessage?.content, - lastMessageTimestamp = timestamp + lastMessageTimestamp = timestamp, + lastMessageJson = lastMessageDomain?.let { gson.toJson(it) } ) localChats.add(chatEntity) } + // Если сервер не вернул Избранное — создаём/получаем его отдельно + val hasFavorites = remoteChats.any { it.type == "favorites" } + if (!hasFavorites) { + try { + val favDto = chatApi.getOrCreateFavorites() + val existingChat = chatDao.getChatByRemoteId(favDto.id) + val favTimestamp = favDto.messages.firstOrNull()?.createdAt?.let { + try { java.time.ZonedDateTime.parse(it).toInstant().toEpochMilli() } catch (e: Exception) { null } + } + val favLastMessageDomain = favDto.messages.firstOrNull()?.toDomain(currentUserId, baseUrl) + val favEntity = chats.data.local.database.ChatEntity( + localId = existingChat?.localId ?: java.util.UUID.randomUUID().toString(), + remoteId = favDto.id, + type = favDto.type, + name = favDto.name ?: "Избранное", + avatar = favDto.avatar, + unreadCount = favDto.unreadCount, + lastMessageText = favDto.messages.firstOrNull()?.content, + lastMessageTimestamp = favTimestamp, + lastMessageJson = favLastMessageDomain?.let { gson.toJson(it) } + ) + localChats.add(favEntity) + Log.d("ChatSyncWorker", "Created favorites chat: ${favDto.id}") + } catch (e: Exception) { + Log.e("ChatSyncWorker", "Failed to get or create favorites", e) + } + } + chatDao.insertChats(localChats) Log.d("ChatSyncWorker", "Synced ${localChats.size} chats") } @@ -150,6 +186,7 @@ class ChatSyncWorker @AssistedInject constructor( ) { val currentUserId = tokenManager.getUserId() ?: "" val gson = com.google.gson.Gson() + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") messages.forEach { dto -> val existing = messageDao.getMessageByServerId(dto.id) @@ -161,19 +198,19 @@ class ChatSyncWorker @AssistedInject constructor( chatId = chatId, senderId = dto.senderId ?: dto.sender?.id ?: currentUserId, senderName = dto.sender?.displayName ?: dto.sender?.username ?: "", - senderAvatar = dto.sender?.avatarUrl, + senderAvatar = dto.sender?.avatarUrl?.let { if (it.startsWith("http")) it else "$baseUrl$it" }, content = dto.content, sequenceId = dto.sequenceId?.toLong() ?: 0L, createdAt = dto.createdAt ?: java.time.ZonedDateTime.now().toString(), mediaType = dto.type ?: "TEXT", - mediaJson = gson.toJson(dto.media.map { + mediaJson = gson.toJson(dto.media.map { mediaItem -> chats.domain.model.Media( - id = it.id, - type = it.type, - url = it.url, - filename = it.filename, - size = it.size, - duration = it.duration + id = mediaItem.id, + type = mediaItem.type, + url = (mediaItem.url as? String ?: "").let { url -> if (url.startsWith("http")) url else "$baseUrl$url" }, + filename = mediaItem.filename, + size = mediaItem.size, + duration = mediaItem.duration ) }), reactionsJson = gson.toJson( diff --git a/client-mobile/chats/data/workers/SendMessageWorker.kt b/client-mobile/chats/data/workers/SendMessageWorker.kt index 02bedbc..99dc541 100644 --- a/client-mobile/chats/data/workers/SendMessageWorker.kt +++ b/client-mobile/chats/data/workers/SendMessageWorker.kt @@ -6,6 +6,7 @@ import androidx.hilt.work.HiltWorker import androidx.work.* import chats.data.local.dao.MessageDao import chats.data.local.database.MessageEntity +import chats.data.remote.api.AttachmentRequest import chats.data.remote.api.ChatApi import chats.data.remote.api.SendMessageRequest import chats.domain.model.MessageStatus @@ -99,9 +100,28 @@ class SendMessageWorker @AssistedInject constructor( ) // Выполняем запрос к API с Idempotency-Key + // Десериализуем media из JSON + val mediaList = try { + val gson = com.google.gson.Gson() + val type = object : com.google.gson.reflect.TypeToken>() {}.type + gson.fromJson>(message.mediaJson, type) ?: emptyList() + } catch (e: Exception) { + emptyList() + } + + val attachments = mediaList.map { + AttachmentRequest( + type = it.type, + url = it.url, + fileName = it.filename ?: "", + fileSize = it.size ?: 0L + ) + }.takeIf { it.isNotEmpty() } + val request = SendMessageRequest( content = message.content, type = message.mediaType.lowercase(), + attachments = attachments, replyToId = message.replyToServerId ) diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 43715f0..6386ea1 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -34,6 +34,7 @@ interface ChatRepository { suspend fun addReaction(messageId: String, emoji: String) suspend fun sendTypingStatus(chatId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) + suspend fun resetUnreadCount(chatId: String) // Локальное хранение suspend fun saveMessage(message: Message) @@ -46,6 +47,7 @@ interface ChatRepository { suspend fun getGifCategories(): List // Управление чатами + suspend fun getOrCreateFavorites(): Chat suspend fun createPersonalChat(userId: String): Chat suspend fun deleteMessage(messageId: String, forEveryone: Boolean) suspend fun editMessage(messageId: String, content: String): Message diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index c2aec5d..1185a36 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -149,31 +149,44 @@ fun ChatDetailScreen( viewModel.setChatId(chatId) } - + // Безопасная группировка сообщений с обработкой ошибок val listItems = remember(state.messages) { val items = mutableListOf() if (state.messages.isEmpty()) return@remember items - // Группируем от старых к новым (для normal layout) - val reversedMessages = state.messages.reversed() - var i = 0 - while (i < reversedMessages.size) { - val isoDate = reversedMessages[i].createdAt - val localDate = viewModel.getLocalDateString(isoDate) - val dayMessages = mutableListOf() - - while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) { - dayMessages.add(reversedMessages[i]) - i++ - } - - // Добавляем заголовок даты - items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate))) - - // Добавляем сообщения - dayMessages.forEach { msg -> - items.add(MessageListItem.MessageItem(msg)) + try { + // Удаляем дубликаты по ID сообщения + val uniqueMessages = state.messages.distinctBy { it.id } + if (uniqueMessages.isEmpty()) return@remember items + + // Группируем от старых к новым (для normal layout) + val reversedMessages = uniqueMessages.reversed() + var i = 0 + while (i < reversedMessages.size) { + val isoDate = reversedMessages[i].createdAt + val localDate = viewModel.getLocalDateString(isoDate) + val dayMessages = mutableListOf() + + while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) { + dayMessages.add(reversedMessages[i]) + i++ + } + + // Добавляем заголовок даты + val safeDateHeader = try { + viewModel.formatDateHeader(isoDate) + } catch (e: Exception) { + isoDate.take(10) + } + items.add(MessageListItem.DateHeader(safeDateHeader)) + + // Добавляем сообщения + dayMessages.forEach { msg -> + items.add(MessageListItem.MessageItem(msg)) + } } + } catch (e: Exception) { + android.util.Log.e("ChatDetailScreen", "Error building listItems", e) } items } @@ -255,28 +268,38 @@ fun ChatDetailScreen( } // Авто-скролл к новым сообщениям - только если мы уже внизу - val lastMessageId = state.messages.lastOrNull()?.id + // Берём firstOrNull, т.к. messages отсортированы по sequenceId убыванию (новые в начале) + val lastMessageId = state.messages.firstOrNull()?.id LaunchedEffect(lastMessageId) { if (lastMessageId != null && listItems.isNotEmpty()) { - val isAtBottom = listState.layoutInfo.run { - val visibleItems = visibleItemsInfo - val totalCount = totalItemsCount - if (totalCount == 0 || visibleItems.isEmpty()) return@run true - visibleItems.last().index >= totalCount - 2 - } - - if (isAtBottom) { - listState.animateScrollToItem(listItems.size - 1) + // Даём время LazyColumn отрисовать новое сообщение и обновить layout + delay(200) + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val totalCount = layoutInfo.totalItemsCount + if (totalCount > 0 && visibleItems.isNotEmpty()) { + val lastVisibleIndex = visibleItems.last().index + val atBottom = lastVisibleIndex >= totalCount - 2 + android.util.Log.d("ChatDetailScreen", "Auto-scroll check: lastVisible=$lastVisibleIndex, total=$totalCount, atBottom=$atBottom, msgId=$lastMessageId") + if (atBottom) { + listState.animateScrollToItem(listItems.size - 1) + } } } } - // Автоматическая прокрутка при инициализации + // Автоматическая прокрутка при инициализации - только один раз + var initialScrollDone by remember { mutableStateOf(false) } LaunchedEffect(listItems.size) { - if (listItems.isNotEmpty()) { - listState.scrollToItem(listItems.size - 1) - viewModel.onInitialScrollDone() - viewModel.markAsRead() + if (!initialScrollDone && listItems.isNotEmpty() && listItems.size > 1) { + try { + listState.scrollToItem(listItems.size - 1) + initialScrollDone = true + viewModel.onInitialScrollDone() + viewModel.markAsRead() + } catch (e: Exception) { + android.util.Log.e("ChatDetailScreen", "Error in auto-scroll", e) + } } } @@ -300,6 +323,20 @@ fun ChatDetailScreen( state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } } + // Авто-прочитка при открытии чата - если мы внизу + LaunchedEffect(Unit) { + delay(500) + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val totalCount = layoutInfo.totalItemsCount + if (totalCount > 0 && visibleItems.isNotEmpty()) { + val lastVisibleIndex = visibleItems.last().index + if (lastVisibleIndex >= totalCount - 2) { + viewModel.markAsRead() + } + } + } + // Авто-прочитка при нахождении внизу списка LaunchedEffect(listState.isScrollInProgress) { if (!listState.isScrollInProgress) { diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 068344c..213732d 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -123,7 +123,16 @@ class ChatDetailViewModel @Inject constructor( // Подписываемся на Flow из Room (Single Source of Truth) observeMessagesFromLocal(chatId) - // Запускаем синхронизацию с сервером + // Немедленно грузим историю с сервера, чтобы не ждать WorkManager + viewModelScope.launch { + try { + repository.getMessages(chatId, limit = 50) + } catch (e: Exception) { + android.util.Log.e("ChatDetailVM", "Initial messages load failed", e) + } + } + + // Дополнительно ставим фоновую синхронизацию через WorkManager viewModelScope.launch { repository.syncMessagesForChat(chatId) } @@ -351,6 +360,7 @@ class ChatDetailViewModel @Inject constructor( currentState.copy(messages = updatedMessages) } repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId) + repository.resetUnreadCount(chatId) signalrNotificationObserver.refresh() } catch (e: Exception) { // Ignore @@ -641,7 +651,8 @@ class ChatDetailViewModel @Inject constructor( fun formatDateHeader(dateString: String): String { return try { - // Парсим ISO 8601 (например 2024-04-14T20:56:00Z) + if (dateString.isBlank()) return "Unknown" + // Парсим ISO 8001 (например 2024-04-14T20:56:00Z) val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } @@ -650,20 +661,23 @@ class ChatDetailViewModel @Inject constructor( // Форматируем в локальное время: "14 апреля" java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date) } catch (e: Exception) { - dateString + android.util.Log.e("ChatDetailVM", "formatDateHeader error: ${e.message}", e) + dateString.take(10) } } fun getLocalDateString(isoDate: String): String { return try { + if (isoDate.isBlank()) return "1970-01-01" val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") } - val date = isoFormat.parse(isoDate) ?: return isoDate + val date = isoFormat.parse(isoDate) ?: return isoDate.take(10) // Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date) } catch (e: Exception) { - isoDate.split("T").first() + android.util.Log.e("ChatDetailVM", "getLocalDateString error: ${e.message}", e) + isoDate.split("T").firstOrNull() ?: "1970-01-01" } } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 2665646..ccb18cf 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -31,6 +31,7 @@ import chats.domain.model.MediaType import chats.domain.model.Media import chats.domain.model.MessageStatus import coil.compose.AsyncImage +import coil.request.ImageRequest import core.presentation.components.AppVideoPlayer import core.presentation.components.AppAudioPlayer import core.presentation.components.AppAvatar @@ -225,7 +226,10 @@ fun MessageBubble( if (isReplyImage && reply.media.isNotEmpty()) { AsyncImage( - model = reply.media.first().url, + model = ImageRequest.Builder(context) + .data(reply.media.first().url) + .crossfade(true) + .build(), contentDescription = null, modifier = Modifier .fillMaxHeight() @@ -281,17 +285,24 @@ fun MessageBubble( // GIF Content (Klipy) if (message.mediaType == MediaType.GIF) { - AsyncImage( - model = message.content, - imageLoader = core.utils.CoilUtils.getGifImageLoader(context), - contentDescription = null, + Box( modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { /* Handle click */ }, - contentScale = ContentScale.Crop - ) + .clickable { /* Handle click */ } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(message.content) + .crossfade(true) + .build(), + imageLoader = core.utils.CoilUtils.getGifImageLoader(context), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } Spacer(modifier = Modifier.height(4.dp)) } @@ -321,17 +332,25 @@ fun MessageBubble( val mediaItem = message.media[0] when { mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> { - AsyncImage( - model = mediaItem.url, - imageLoader = core.utils.CoilUtils.getGifImageLoader(context), - contentDescription = null, + Box( modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp) .clip(RoundedCornerShape(8.dp)) - .clickable { onMediaClick(mediaItem) }, - contentScale = ContentScale.Crop - ) + .clickable { onMediaClick(mediaItem) } + ) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(mediaItem.url) + .crossfade(true) + .memoryCacheKey(mediaItem.url) + .build(), + imageLoader = core.utils.CoilUtils.getGifImageLoader(context), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } } message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> { Box( @@ -708,7 +727,10 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) { Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) { if (isImage || isVideo) { AsyncImage( - model = media.url, + model = ImageRequest.Builder(context) + .data(media.url) + .crossfade(true) + .build(), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop