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