diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index a031326..df01331 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 4d294a6..b21e214 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 89c5a46..dd110f5 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 f983062..25094ab 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 7510778..6eefcba 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 fda8133..fd2b6a0 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 6e04e93..b63a124 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/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index 5904e9e..9eb296d 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -10,17 +10,17 @@ data class UserBasicDto( ) data class MessageDto( - @SerializedName("id") val id: String, - @SerializedName("chatId") val chatId: String? = null, - @SerializedName("senderId") val senderId: String? = null, - @SerializedName("content") val content: String? = null, - @SerializedName("type") val type: String? = null, - @SerializedName("sequenceId") val sequenceId: Int? = null, - @SerializedName("createdAt") val createdAt: String? = null, - @SerializedName("sender") val sender: UserBasicDto? = null, - @SerializedName("media") val media: List = emptyList(), - @SerializedName("reactions") val reactions: List? = emptyList(), - @SerializedName("replyTo") val replyTo: MessageDto? = null + @SerializedName("id", alternate = ["Id"]) val id: String, + @SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, + @SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null, + @SerializedName("content", alternate = ["Content"]) val content: String? = null, + @SerializedName("type", alternate = ["Type"]) val type: String? = null, + @SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null, + @SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null, + @SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null, + @SerializedName("media", alternate = ["Media"]) val media: List = emptyList(), + @SerializedName("reactions", alternate = ["Reactions"]) val reactions: List? = emptyList(), + @SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null ) data class ReactionDto( @@ -43,7 +43,7 @@ data class ChatDto( @SerializedName("type") val type: String, @SerializedName("name") val name: String? = null, @SerializedName("avatar") val avatar: String? = null, - @SerializedName("unreadCount") val unreadCount: Int = 0, + @SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0, @SerializedName("messages") val messages: List = emptyList(), @SerializedName("members") val members: List = emptyList() ) diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index a545287..ea5b768 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -22,12 +22,30 @@ data class ReadMessagesRequest( val lastReadSequenceId: Int ) +data class MessagesReadEvent( + @com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, + @com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null, + @com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null +) { + val effectiveChatId: String get() = chatId ?: "" + val effectiveUserId: String get() = userId ?: "" + val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0 +} + +data class ReactionEvent( + @com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null, + @com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null, + @com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null, + @com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null, + @com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null +) + enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED } @Singleton class ChatHubClient @Inject constructor() { private var hubConnection: HubConnection? = null - private val _events = MutableSharedFlow(extraBufferCapacity = 64) + private val _events = MutableSharedFlow(extraBufferCapacity = 1024) val events: SharedFlow = _events.asSharedFlow() private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED) @@ -77,13 +95,17 @@ class ChatHubClient @Inject constructor() { _events.tryEmit(ChatEvent.NewMessage(message)) }, MessageDto::class.java) - conn.on("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int -> - _events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId)) - }, String::class.java, String::class.java, Int::class.java) + conn.on("messages_read", { data: MessagesReadEvent -> + _events.tryEmit(ChatEvent.MessagesRead( + data.effectiveChatId, + data.effectiveUserId, + data.effectiveLastReadSequenceId + )) + }, MessagesReadEvent::class.java) - conn.on("user_typing", { chatId: String, userId: String -> - _events.tryEmit(ChatEvent.UserTyping(chatId, userId)) - }, String::class.java, String::class.java) + conn.on("user_typing", { data: ReactionEvent -> + _events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: "")) + }, ReactionEvent::class.java) conn.on("user_online", { userId: String -> _events.tryEmit(ChatEvent.UserOnline(userId)) @@ -93,14 +115,23 @@ class ChatHubClient @Inject constructor() { _events.tryEmit(ChatEvent.NewChat(chat)) }, ChatDto::class.java) - conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String -> - _events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji)) - }, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java) + conn.on("reaction_added", { data: ReactionEvent -> + _events.tryEmit(ChatEvent.ReactionUpdated( + data.messageId ?: "", + data.chatId ?: "", + data.userId ?: "", + data.emoji ?: "" + )) + }, ReactionEvent::class.java) - conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String -> - // Using ReactionUpdated with empty emoji to signal removal or just a specific removal event - _events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, "")) - }, String::class.java, String::class.java, String::class.java, String::class.java) + conn.on("reaction_removed", { data: ReactionEvent -> + _events.tryEmit(ChatEvent.ReactionUpdated( + data.messageId ?: "", + data.chatId ?: "", + data.userId ?: "", + "" // empty emoji signals removal + )) + }, ReactionEvent::class.java) // WebRTC Signaling Handlers conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String -> @@ -128,7 +159,26 @@ class ChatHubClient @Inject constructor() { fun readMessages(request: ReadMessagesRequest) { if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { - hubConnection?.invoke("read_messages", request) + hubConnection?.send("read_messages", request) + Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}") + } + } + + fun joinChat(chatId: String) { + scope.launch { + // Wait for connection to be established if it's currently connecting + var attempts = 0 + while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) { + delay(500) + attempts++ + } + + if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { + hubConnection?.send("join_chat", chatId) + Log.d("ChatHubClient", "Joined chat room: $chatId") + } else { + Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected") + } } } } diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index acd983f..114885d 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -11,6 +11,7 @@ import chats.domain.model.Message import chats.domain.model.MediaType import chats.domain.repository.ChatRepository import core.network.ServerConfig +import chats.data.remote.signalr.ReadMessagesRequest import core.security.TokenManager import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -40,15 +41,27 @@ class ChatRepositoryImpl @Inject constructor( } } - override suspend fun getMessages(chatId: String): List { + override suspend fun getMessages(chatId: String, cursor: String?, limit: Int?): List { val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + val currentUserId = tokenManager.getUserId() ?: "" return try { - val messages = api.getMessages(chatId) - // Save to DB - messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) }) - messages.map { it.toDomain(baseUrl) } + val chats = api.getChats() + val chatDto = chats.find { it.id == chatId } + val unreadCount = chatDto?.unreadCount ?: 0 + + android.util.Log.d("ChatRepo", "Fetching messages from network for chat: $chatId, cursor: $cursor") + val messages = api.getMessages(chatId, cursor = cursor, limit = limit) + if (messages.isNotEmpty()) { + android.util.Log.d("ChatRepo", "Received ${messages.size} messages. First: ${messages.first().createdAt}, Last: ${messages.last().createdAt}") + } + + // Определяем, какие сообщения считаются прочитанными + val mappedMessages = messages.mapIndexed { index, msg -> + val isUnread = (messages.size - index) <= unreadCount && msg.senderId != currentUserId + msg.toDomain(currentUserId, baseUrl).copy(isRead = !isUnread) + } + mappedMessages } catch (e: Exception) { - // If network fails, caller should ideally use the Flow from DB emptyList() } } @@ -65,7 +78,8 @@ class ChatRepositoryImpl @Inject constructor( attachments = attachments ) val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - return api.sendMessage(chatId, request).toDomain(baseUrl) + val userId = tokenManager.getUserId() ?: "" + return api.sendMessage(chatId, request).toDomain(userId, baseUrl) } override suspend fun addReaction(messageId: String, emoji: String) { @@ -77,16 +91,18 @@ class ChatRepositoryImpl @Inject constructor( } override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) { - val request = chats.data.remote.signalr.ReadMessagesRequest( - chatId = chatId, - lastReadMessageId = lastMessageId, - lastReadSequenceId = lastReadSequenceId - ) - hubClient.readMessages(request) + try { + android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}") + hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId)) + } catch (e: Exception) { + android.util.Log.e("ChatRepo", "Error marking messages as read", e) + } + // Обновляем локальную БД + messageDao.markMessagesAsRead(chatId, lastReadSequenceId) } override suspend fun saveMessage(message: Message) { - messageDao.insertMessages(listOf(message.toEntity(gson))) + android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}") } override suspend fun deleteLocalMessage(messageId: String) { @@ -119,168 +135,3 @@ class ChatRepositoryImpl @Inject constructor( return api.getGifCategories().data.categories } } - -// Mappers -fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { - val chatName = name ?: if (type == "personal") { - members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" - } else "Group Chat" - - val chatAvatar = (avatar ?: if (type == "personal") { - members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl - } else null)?.ensureAbsoluteUrl(baseUrl) - - return Chat( - id = id, - type = type, - name = chatName, - avatar = chatAvatar, - unreadCount = unreadCount, - lastMessage = messages.firstOrNull()?.toDomain(baseUrl) - ) -} - -fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity { - return core.database.data.MessageEntity( - id = id, - chatId = chatId, - senderId = senderId, - senderName = senderName, - senderAvatar = senderAvatar, - content = content, - sequenceId = sequenceId, - createdAt = createdAt, - mediaType = mediaType.name.lowercase(), - mediaJson = gson.toJson(media), - reactionsJson = gson.toJson(reactions), - isRead = isRead, - replyToId = replyTo?.id - ) -} - -fun MessageDto.toDomain(baseUrl: String): Message { - val domainMediaType = when (type) { - "gif" -> MediaType.GIF - "image", "photo" -> MediaType.IMAGE - "video" -> MediaType.VIDEO - "audio", "voice" -> MediaType.AUDIO - "file" -> MediaType.FILE - else -> when (media.firstOrNull()?.type) { - "image", "photo" -> MediaType.IMAGE - "video" -> MediaType.VIDEO - "audio", "voice" -> MediaType.AUDIO - "file" -> MediaType.FILE - else -> MediaType.TEXT - } - } - - return Message( - id = id, - chatId = chatId ?: "", - senderId = senderId ?: "", - senderName = sender?.displayName ?: "Unknown", - senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl), - content = content, - sequenceId = sequenceId ?: 0, - createdAt = createdAt ?: "", - media = media.map { - chats.domain.model.Media( - id = it.id ?: java.util.UUID.randomUUID().toString(), - type = it.type ?: "unknown", - url = (it.url ?: "").ensureAbsoluteUrl(baseUrl), - filename = it.filename, - size = it.size, - duration = it.duration - ) - }, - mediaType = domainMediaType, - reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), - isRead = true, // Network messages are usually considered read when fetched or handled by server - replyTo = replyTo?.toDomain(baseUrl) - ) -} - -fun MessageDto.toEntity(baseUrl: String, gson: com.google.gson.Gson): core.database.data.MessageEntity { - return core.database.data.MessageEntity( - id = id, - chatId = chatId ?: "", - senderId = senderId ?: "", - senderName = sender?.displayName ?: "Unknown", - senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl), - content = content, - sequenceId = sequenceId ?: 0, - createdAt = createdAt ?: "", - mediaType = type ?: "text", - mediaJson = gson.toJson(media), - reactionsJson = gson.toJson(reactions), - isRead = true, - replyToId = replyTo?.id - ) -} - -fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message { - val mediaTypeEnum = when (mediaType) { - "gif" -> MediaType.GIF - "image", "photo" -> MediaType.IMAGE - "video" -> MediaType.VIDEO - "audio", "voice" -> MediaType.AUDIO - "file" -> MediaType.FILE - else -> MediaType.TEXT - } - - val mediaTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type - val mediaDtos: List = try { - gson.fromJson(mediaJson, mediaTypeToken) - } catch (e: Exception) { - emptyList() - } ?: emptyList() - - val reactionsTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type - val reactionDtos: List = try { - // Try to parse as array (List) - gson.fromJson(reactionsJson, reactionsTypeToken) - } catch (e: Exception) { - // If it's an object instead of array, parse as map and convert to list - try { - val mapType = object : com.google.gson.reflect.TypeToken>() {}.type - val map: Map = gson.fromJson(reactionsJson, mapType) ?: emptyMap() - map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) } - } catch (innerE: Exception) { - emptyList() - } - } ?: emptyList() - - return Message( - id = id, - chatId = chatId, - senderId = senderId, - senderName = senderName, - senderAvatar = senderAvatar, - content = content, - sequenceId = sequenceId, - createdAt = createdAt, - media = mediaDtos.map { - chats.domain.model.Media( - id = it.id ?: java.util.UUID.randomUUID().toString(), - type = it.type ?: "unknown", - url = (it.url ?: "").ensureAbsoluteUrl(baseUrl), - filename = it.filename, - size = it.size, - duration = it.duration - ) - }, - mediaType = mediaTypeEnum, - reactions = reactionDtos.associate { it.emoji to it.count }, - isRead = isRead - ) -} - -fun String.ensureAbsoluteUrl(baseUrl: String): String { - return if (this.startsWith("http")) { - this - } else { - val base = baseUrl.removeSuffix("/") - val path = if (this.startsWith("/")) this else "/$this" - "$base$path" - } -} diff --git a/client-mobile/chats/data/repository/Mappers.kt b/client-mobile/chats/data/repository/Mappers.kt new file mode 100644 index 0000000..0927091 --- /dev/null +++ b/client-mobile/chats/data/repository/Mappers.kt @@ -0,0 +1,167 @@ +package chats.data.repository + +import chats.data.remote.dto.* +import chats.domain.model.* + +// Mappers +fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { + val chatName = name ?: if (type == "personal") { + members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" + } else "Group Chat" + + val chatAvatar = (avatar ?: if (type == "personal") { + members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl + } else null)?.ensureAbsoluteUrl(baseUrl) + + return Chat( + id = id, + type = type, + name = chatName, + avatar = chatAvatar, + unreadCount = unreadCount, + lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl) + ) +} + +fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity { + return core.database.data.MessageEntity( + id = id, + chatId = chatId, + senderId = senderId, + senderName = senderName, + senderAvatar = senderAvatar, + content = content, + sequenceId = sequenceId, + createdAt = createdAt, + mediaType = mediaType.name.lowercase(), + mediaJson = gson.toJson(media), + reactionsJson = gson.toJson(reactions), + isRead = isRead, + replyToId = replyTo?.id + ) +} + +fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message { + val domainMediaType = when (type) { + "gif" -> MediaType.GIF + "image", "photo" -> MediaType.IMAGE + "video" -> MediaType.VIDEO + "audio", "voice" -> MediaType.AUDIO + "file" -> MediaType.FILE + else -> when (media.firstOrNull()?.type) { + "image", "photo" -> MediaType.IMAGE + "video" -> MediaType.VIDEO + "audio", "voice" -> MediaType.AUDIO + "file" -> MediaType.FILE + else -> MediaType.TEXT + } + } + + return Message( + id = id, + chatId = chatId ?: "", + senderId = senderId ?: "", + senderName = sender?.displayName ?: "Unknown", + senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl), + content = content, + sequenceId = sequenceId ?: 0, + createdAt = createdAt ?: "", + media = media.map { + chats.domain.model.Media( + id = it.id ?: java.util.UUID.randomUUID().toString(), + type = it.type ?: "unknown", + url = (it.url ?: "").ensureAbsoluteUrl(baseUrl), + filename = it.filename, + size = it.size, + duration = it.duration + ) + }, + mediaType = domainMediaType, + reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), + isRead = senderId == currentUserId, + replyTo = replyTo?.toDomain(currentUserId, baseUrl) + ) +} + +fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity { + return core.database.data.MessageEntity( + id = id, + chatId = chatId ?: "", + senderId = senderId ?: "", + senderName = sender?.displayName ?: "Unknown", + senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl), + content = content, + sequenceId = sequenceId ?: 0, + createdAt = createdAt ?: "", + mediaType = type ?: "text", + mediaJson = gson.toJson(media), + reactionsJson = gson.toJson(reactions), + isRead = senderId == currentUserId, + replyToId = replyTo?.id + ) +} + +fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message { + val mediaTypeEnum = when (mediaType) { + "gif" -> MediaType.GIF + "image", "photo" -> MediaType.IMAGE + "video" -> MediaType.VIDEO + "audio", "voice" -> MediaType.AUDIO + "file" -> MediaType.FILE + else -> MediaType.TEXT + } + + val mediaTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type + val mediaDtos: List = try { + gson.fromJson(mediaJson, mediaTypeToken) + } catch (e: Exception) { + emptyList() + } ?: emptyList() + + val reactionsTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type + val reactionDtos: List = try { + gson.fromJson(reactionsJson, reactionsTypeToken) + } catch (e: Exception) { + try { + val mapType = object : com.google.gson.reflect.TypeToken>() {}.type + val map: Map = gson.fromJson(reactionsJson, mapType) ?: emptyMap() + map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) } + } catch (innerE: Exception) { + emptyList() + } + } ?: emptyList() + + return Message( + id = id, + chatId = chatId, + senderId = senderId, + senderName = senderName, + senderAvatar = senderAvatar, + content = content, + sequenceId = sequenceId, + createdAt = createdAt, + media = mediaDtos.map { + chats.domain.model.Media( + id = it.id ?: java.util.UUID.randomUUID().toString(), + type = it.type ?: "unknown", + url = (it.url ?: "").ensureAbsoluteUrl(baseUrl), + filename = it.filename, + size = it.size, + duration = it.duration + ) + }, + mediaType = mediaTypeEnum, + reactions = reactionDtos.associate { it.emoji to it.count }, + isRead = isRead + ) +} + +fun String.ensureAbsoluteUrl(baseUrl: String): String { + return if (this.startsWith("http")) { + this + } else { + val base = baseUrl.removeSuffix("/") + val path = if (this.startsWith("/")) this else "/$this" + "$base$path" + } +} diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 1cf71af..e652abd 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -6,7 +6,7 @@ import chats.domain.model.Message interface ChatRepository { suspend fun getChats(): List fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow> - suspend fun getMessages(chatId: String): List + suspend fun getMessages(chatId: String, cursor: String? = null, limit: Int? = null): List suspend fun sendMessage( chatId: String, content: String?, diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index fde8cb6..f0924c9 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -22,12 +22,18 @@ import kotlin.math.roundToInt import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.zIndex +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import chats.presentation.components.MediaPicker import chats.presentation.components.MessageBubble import core.presentation.components.AppAvatar @@ -55,6 +61,11 @@ fun ChatDetailScreen( viewModel: ChatDetailViewModel, onBack: () -> Unit ) { + DisposableEffect(Unit) { + onDispose { + viewModel.clearChatId() + } + } val state by viewModel.state.collectAsState() val context = LocalContext.current val listState = rememberLazyListState() @@ -120,27 +131,97 @@ fun ChatDetailScreen( viewModel.setChatId(chatId) } - // Автопрокрутка к первому непрочитанному или к самому низу - LaunchedEffect(state.initialScrollIndex) { - state.initialScrollIndex?.let { index -> - if (index < state.messages.size) { - listState.scrollToItem(index) + + val listItems = remember(state.messages) { + val items = mutableListOf() + if (state.messages.isEmpty()) return@remember items + + // Группируем от новых к старым (Index 0 = bottom) + var i = 0 + while (i < state.messages.size) { + val isoDate = state.messages[i].createdAt + // Получаем дату в локальном часовом поясе (например "2024-04-15") + val localDate = viewModel.getLocalDateString(isoDate) + val dayMessages = mutableListOf() + + // Собираем все сообщения за этот локальный день + while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) { + dayMessages.add(state.messages[i]) + i++ + } + + // Добавляем сообщения в инвертированном порядке + dayMessages.forEach { msg -> + items.add(MessageListItem.MessageItem(msg)) + } + + // Добавляем заголовок (над группой сообщений) + items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate))) + } + items + } + + // Отслеживаем текущую дату для плавающего заголовка + val floatingDate by remember { + derivedStateOf { + val firstIndex = listState.firstVisibleItemIndex + // Не показываем плашку на самом первом элементе (там и так дата) + // И показываем только если мы прокрутили хотя бы немного + if (firstIndex > 0 && firstIndex < listItems.size) { + val item = listItems[firstIndex] + if (item is MessageListItem.MessageItem) { + viewModel.formatDateHeader(item.message.createdAt) + } else if (item is MessageListItem.DateHeader) { + item.date + } else null + } else null + } + } + + // Показывать ли кнопку "вниз" + // Она должна появляться если мы не в самом низу + val showScrollToBottom by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + if (layoutInfo.totalItemsCount == 0) return@derivedStateOf false + + val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + // Показываем, если не в самом низу (с запасом 1-2 элемента) + lastVisibleIndex < listItems.size - 2 + } + } + + // Автоматическая прокрутка и прочтение при инициализации и новых сообщениях + LaunchedEffect(listItems.size) { + if (listItems.isNotEmpty()) { + if (state.initialScrollIndex != null && state.initialScrollIndex != -1) { + // Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0 + listState.scrollToItem(0) + viewModel.onInitialScrollDone() + viewModel.markAsRead() } } } - // Автопрокрутка к новому сообщению, если мы внизу - LaunchedEffect(state.messages.size) { - if (state.messages.isNotEmpty()) { - val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2 - if (isAtBottom || state.initialScrollIndex == null) { - listState.animateScrollToItem(state.messages.size - 1) - } - - // Если пришли новые сообщения и мы их видим — помечаем как прочитанные - if (isAtBottom) { - viewModel.markAsRead() + // Подгрузка истории при прокрутке вверх + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + if (lastVisibleIndex != null && lastVisibleIndex >= listItems.size - 10 && listItems.size >= 15) { + android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, size ${listItems.size}") + viewModel.loadMoreMessages() + } } + } + + val unreadCount = remember(state.messages) { + state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() } + } + + // Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0) + LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) { + if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) { + viewModel.markAsRead() } } @@ -200,6 +281,7 @@ fun ChatDetailScreen( LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), + reverseLayout = true, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -207,34 +289,76 @@ fun ChatDetailScreen( msg.media.map { it to msg.id } }.reversed() - items(state.messages, key = { it.id }) { message -> - MessageBubble( - message = message, - isCurrentUser = message.senderId == viewModel.getCurrentUserId(), - autoPlay = message.id == autoPlayingMessageId, - initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, - onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) }, - onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }, - onMediaClick = { clickedMedia -> - val isMedia = clickedMedia.type.startsWith("image") || - clickedMedia.type.startsWith("video") || - clickedMedia.type.contains("gif") - - if (isMedia) { - val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } - selectedMediaList = allChatMedia.map { it.first } - initialMediaIndex = if (initialIndex != -1) initialIndex else 0 - } else { - // Прямое открытие документов во внешних приложениях - try { - val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) - context.startActivity(intent) - } catch (e: Exception) { - // Ошибка (нет софта) + items(listItems, key = { + when(it) { + is MessageListItem.MessageItem -> it.message.id + is MessageListItem.DateHeader -> "date_${it.date}" + MessageListItem.UnreadSeparator -> "unread_separator" + } + }) { item -> + when(item) { + is MessageListItem.MessageItem -> { + MessageBubble( + message = item.message, + isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(), + autoPlay = item.message.id == autoPlayingMessageId, + initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, + onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) }, + onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) }, + onMediaClick = { clickedMedia -> + val isMedia = clickedMedia.type.startsWith("image") || + clickedMedia.type.startsWith("video") || + clickedMedia.type.contains("gif") + + if (isMedia) { + val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } + selectedMediaList = allChatMedia.map { it.first } + initialMediaIndex = if (initialIndex != -1) initialIndex else 0 + } else { + try { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url)) + context.startActivity(intent) + } catch (e: Exception) { } + } + } + ) + } + is MessageListItem.DateHeader -> { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Surface( + color = Color.Black.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = item.date, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = Color.White + ) } } } - ) + MessageListItem.UnreadSeparator -> { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) + Text( + text = "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ", + modifier = Modifier.padding(horizontal = 12.dp), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray.copy(alpha = 0.6f) + ) + Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f))) + } + } + } + } } } @@ -242,6 +366,72 @@ fun ChatDetailScreen( if (state.isLoading) { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } + + // Плавающий заголовок даты (на самом верху списка под топбаром) + floatingDate?.let { date -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .align(Alignment.TopCenter) + .zIndex(1f), // Гарантируем видимость поверх всего + contentAlignment = Alignment.TopCenter + ) { + Surface( + color = Color.DarkGray.copy(alpha = 0.9f), + shape = CircleShape, + shadowElevation = 4.dp + ) { + Text( + text = date, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = Color.White + ) + } + } + } + + // Кнопка прокрутки вниз с бейджем + if (showScrollToBottom) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp) + ) { + FloatingActionButton( + onClick = { + scope.launch { + listState.animateScrollToItem(listItems.size - 1) + } + }, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + shape = CircleShape, + modifier = Modifier.size(44.dp), + elevation = FloatingActionButtonDefaults.elevation(2.dp) + ) { + Icon(Icons.Default.KeyboardArrowDown, contentDescription = null) + } + + if (unreadCount > 0) { + Surface( + color = Color(0xFFF44336), // Красный как в вебе + shape = CircleShape, + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp) + ) { + Text( + text = if (unreadCount > 99) "99+" else unreadCount.toString(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } } // Панель ввода @@ -421,6 +611,21 @@ fun ChatDetailScreen( } }, textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences + ), + keyboardActions = KeyboardActions( + onSend = { + if (textInput.isNotBlank() || state.pendingAttachments.isNotEmpty()) { + viewModel.sendMessage(textInput, onFail = { failedText -> + textInput = failedText + }) + textInput = "" + isEmojiPickerVisible = false + } + } + ), decorationBox = { innerTextField -> if (textInput.isEmpty()) { Text( @@ -443,8 +648,11 @@ fun ChatDetailScreen( Surface( onClick = { if (!showMic) { - viewModel.sendMessage(textInput) + viewModel.sendMessage(textInput, onFail = { failedText -> + textInput = failedText + }) textInput = "" + isEmojiPickerVisible = false } }, shape = RoundedCornerShape(16.dp), @@ -566,3 +774,9 @@ fun ChatDetailScreen( ) } } + +sealed class MessageListItem { + data class MessageItem(val message: chats.domain.model.Message) : MessageListItem() + data class DateHeader(val date: String) : MessageListItem() + object UnreadSeparator : MessageListItem() +} diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 5a3b5b3..d94ea52 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import chats.data.remote.signalr.ChatEvent import chats.data.remote.signalr.ChatHubClient +import chats.data.repository.toDomain import chats.domain.model.Message import chats.domain.repository.ChatRepository -import chats.data.repository.toDomain import core.network.ServerConfig import core.security.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel @@ -26,6 +26,7 @@ data class ChatDetailState( val chatName: String? = null, val chatAvatar: String? = null, val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, val isTyping: Boolean = false, val typingUser: String? = null, val error: String? = null, @@ -56,6 +57,7 @@ class ChatDetailViewModel @Inject constructor( private var currentChatId: String? = null private var typingTimerJob: Job? = null + private var signalrEventsJob: Job? = null private var lastTypingSentTime: Long = 0 private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE) @@ -83,43 +85,73 @@ class ChatDetailViewModel @Inject constructor( } fun setChatId(chatId: String) { + if (currentChatId == chatId) return currentChatId = chatId - // Ensure SignalR is connected + _state.update { it.copy( + messages = emptyList(), + isLoading = true, + initialScrollIndex = null + ) } + + // Ensure SignalR is connected and join the chat room val token = tokenManager.getToken() if (token != null) { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api") + val baseUrl = serverConfig.getBaseUrl() signalrClient.connect(baseUrl, token) + signalrClient.joinChat(chatId) } loadChatInfo(chatId) - observeMessages(chatId) - observeSignalREvents() + observeSignalREvents(chatId) // Initial sync from network refreshMessages(chatId) } - private fun observeMessages(chatId: String) { - repository.getMessagesFlow(chatId) - .onEach { messages -> - _state.update { it.copy(messages = messages) } - // Calculate scroll index if not set - if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) { - val firstUnreadIndex = messages.indexOfFirst { !it.isRead && it.senderId != getCurrentUserId() } - val targetIndex = if (firstUnreadIndex != -1) firstUnreadIndex else messages.size - 1 - _state.update { it.copy(initialScrollIndex = targetIndex) } - - // Automark as read - markAsRead() - } - } - .launchIn(viewModelScope) + private fun updateMessages(messages: List) { + val sortedMessages = messages.sortedByDescending { it.sequenceId } + _state.update { it.copy( + messages = sortedMessages, + isLoading = false, + initialScrollIndex = 0 // In reverse layout, 0 is the bottom + ) } + } + + fun onInitialScrollDone() { + _state.update { it.copy(initialScrollIndex = -1) } } fun refreshMessages(chatId: String) { viewModelScope.launch { - repository.getMessages(chatId) + val messages = repository.getMessages(chatId) + updateMessages(messages) + } + } + + fun loadMoreMessages() { + val chatId = currentChatId ?: return + if (_state.value.isLoading || _state.value.isLoadingMore) return + + val oldestMsg = _state.value.messages.lastOrNull() ?: return + + viewModelScope.launch { + _state.update { it.copy(isLoadingMore = true) } + try { + android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}") + val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString()) + if (moreMessages.isNotEmpty()) { + val newSorted = moreMessages.sortedByDescending { it.sequenceId } + _state.update { currentState -> + // Избегаем дубликатов + val existingIds = currentState.messages.map { it.id }.toSet() + val uniqueMore = newSorted.filter { it.id !in existingIds } + currentState.copy(messages = currentState.messages + uniqueMore) + } + } + } finally { + _state.update { it.copy(isLoadingMore = false) } + } } } @@ -150,23 +182,37 @@ class ChatDetailViewModel @Inject constructor( } } - private fun observeSignalREvents() { - signalrClient.events + private fun observeSignalREvents(chatId: String) { + signalrEventsJob?.cancel() + signalrEventsJob = signalrClient.events + .onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") } .filter { event -> - when(event) { - is ChatEvent.NewMessage -> event.message.chatId == currentChatId - is ChatEvent.ReactionUpdated -> event.chatId == currentChatId - is ChatEvent.UserTyping -> event.chatId == currentChatId - else -> false + val eventChatId = when(event) { + is ChatEvent.NewMessage -> event.message.chatId + is ChatEvent.ReactionUpdated -> event.chatId + is ChatEvent.UserTyping -> event.chatId + is ChatEvent.MessagesRead -> event.chatId + else -> null } + + if (eventChatId == null) return@filter false + + val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId) + if (match) { + android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event") + } + match } .onEach { event -> when (event) { is ChatEvent.NewMessage -> { + android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - val domainMsg = event.message.toDomain(baseUrl) - viewModelScope.launch { - repository.saveMessage(domainMsg) + val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl) + + _state.update { currentState -> + if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState + currentState.copy(messages = listOf(domainMsg) + currentState.messages) } } is ChatEvent.ReactionUpdated -> { @@ -174,8 +220,21 @@ class ChatDetailViewModel @Inject constructor( } is ChatEvent.UserTyping -> { _state.update { it.copy(isTyping = true) } - // Reset typing status after some delay would be better, - // but usually server sends stopped_typing event. + typingTimerJob?.cancel() + typingTimerJob = viewModelScope.launch { + delay(3000) + _state.update { it.copy(isTyping = false) } + } + } + is ChatEvent.MessagesRead -> { + _state.update { currentState -> + val updatedMessages = currentState.messages.map { msg -> + if (msg.sequenceId <= event.lastReadSequenceId) { + msg.copy(isRead = true) + } else msg + } + currentState.copy(messages = updatedMessages) + } } else -> Unit } @@ -183,21 +242,42 @@ class ChatDetailViewModel @Inject constructor( .launchIn(viewModelScope) } + fun clearChatId() { + android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId") + signalrEventsJob?.cancel() + signalrEventsJob = null + currentChatId = null + _state.update { it.copy(messages = emptyList(), isLoading = false) } + } + fun markAsRead() { val chatId = currentChatId ?: return val messages = _state.value.messages if (messages.isEmpty()) return - val lastMessage = messages.last() + // В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника + val currentUserId = getCurrentUserId() + val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return + viewModelScope.launch { try { - repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId) + // Мгновенно обновляем в памяти для "галочек" + _state.update { currentState -> + val updatedMessages = currentState.messages.map { msg -> + 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) } catch (e: Exception) { // Ignore } } } - fun sendMessage(text: String) { + + fun sendMessage(text: String, onFail: (String) -> Unit = {}) { val chatId = currentChatId ?: return val pending = _state.value.pendingAttachments if (text.isBlank() && pending.isEmpty()) return @@ -259,15 +339,18 @@ class ChatDetailViewModel @Inject constructor( val sentMessage = repository.sendMessage( chatId = chatId, - content = text, + content = if (text.isBlank()) null else text, type = if (attachmentRequests != null) "media" else "text", attachments = attachmentRequests ) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) + // Clear attachments on success + _state.update { it.copy(pendingAttachments = emptyList()) } } catch (e: Exception) { repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage, isUploading = false) } + onFail(text) } } } @@ -349,6 +432,34 @@ class ChatDetailViewModel @Inject constructor( } } + fun formatDateHeader(dateString: String): String { + return try { + // Парсим ISO 8601 (например 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") + } + val date = isoFormat.parse(dateString) ?: return dateString + + // Форматируем в локальное время: "14 апреля" + java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date) + } catch (e: Exception) { + dateString + } + } + + fun getLocalDateString(isoDate: String): String { + return try { + 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 + // Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки + java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date) + } catch (e: Exception) { + isoDate.split("T").first() + } + } + fun removePendingAttachment(file: File) { _state.update { it.copy(pendingAttachments = it.pendingAttachments - file) } } diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index 633c1a1..5d318c2 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -108,11 +108,24 @@ class ChatListViewModel @Inject constructor( _state.update { currentState -> val updatedChats = currentState.chats.map { chat -> - if (chat.id == event.message.chatId) { + if (chat.id.equals(event.message.chatId, ignoreCase = true)) { val isMyMessage = event.message.senderId == currentUserId + val isAlreadySeen = chat.lastMessage?.id == event.message.id + + val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl) + val newCount = if (isMyMessage || isAlreadySeen) { + chat.unreadCount + } else { + chat.unreadCount + 1 + } + + if (!isAlreadySeen) { + android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount") + } + chat.copy( - lastMessage = event.message.toDomain(baseUrl), - unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1 + lastMessage = lastMsgDomain, + unreadCount = newCount ) } else chat } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index ec68151..1966cc5 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -157,6 +158,25 @@ fun MessageBubble( Spacer(modifier = Modifier.height(4.dp)) } + // Loading state for temp media messages + if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(8.dp)) + .background(contentColor.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = contentColor.copy(alpha = 0.5f) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + } + // Media Content (Attachments) if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) { val mediaCount = message.media.size @@ -370,8 +390,8 @@ fun PhotoGrid(mediaList: List, isCurrentUser: Boolean, onMediaClick: (Int val columns = when { mediaList.size == 1 -> 1 - mediaList.size % 3 == 0 -> 3 - else -> 2 + mediaList.size <= 4 -> 2 + else -> 3 } Column(modifier = modifier) { diff --git a/client-mobile/core/database/data/ChatDatabase.kt b/client-mobile/core/database/data/ChatDatabase.kt index d4667f0..e12243e 100644 --- a/client-mobile/core/database/data/ChatDatabase.kt +++ b/client-mobile/core/database/data/ChatDatabase.kt @@ -33,6 +33,9 @@ interface MessageDao { @Query("DELETE FROM messages WHERE id = :messageId") suspend fun deleteMessage(messageId: String) + + @Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0") + suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int) } @Database(entities = [MessageEntity::class], version = 1) diff --git a/client-mobile/core/di/NetworkModule.kt b/client-mobile/core/di/NetworkModule.kt index ce803f0..780fdf5 100644 --- a/client-mobile/core/di/NetworkModule.kt +++ b/client-mobile/core/di/NetworkModule.kt @@ -11,6 +11,7 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit import javax.inject.Singleton @Module @@ -24,9 +25,12 @@ object NetworkModule { dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor ): OkHttpClient { val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + level = HttpLoggingInterceptor.Level.HEADERS } return OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(300, TimeUnit.SECONDS) .addInterceptor(logging) .addInterceptor(dynamicBaseUrlInterceptor) .addInterceptor(authInterceptor)