Рабочие счетчики и переработка чата

This commit is contained in:
Халимов Рустам
2026-04-15 01:14:55 +03:00
parent 58fdf1aca1
commit 487cb1b12b
18 changed files with 725 additions and 292 deletions

View File

@@ -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<MediaItemDto> = emptyList(),
@SerializedName("reactions") val reactions: List<ReactionDto>? = 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<MediaItemDto> = emptyList(),
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = 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<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)

View File

@@ -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<ChatEvent>(extraBufferCapacity = 64)
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
val events: SharedFlow<ChatEvent> = _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")
}
}
}
}

View File

@@ -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<Message> {
override suspend fun getMessages(chatId: String, cursor: String?, limit: Int?): List<Message> {
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<List<chats.data.remote.dto.MediaItemDto>>() {}.type
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
gson.fromJson(mediaJson, mediaTypeToken)
} catch (e: Exception) {
emptyList()
} ?: emptyList()
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
// Try to parse as array (List<ReactionDto>)
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<Map<String, Int>>() {}.type
val map: Map<String, Int> = 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"
}
}

View File

@@ -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<List<chats.data.remote.dto.MediaItemDto>>() {}.type
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
gson.fromJson(mediaJson, mediaTypeToken)
} catch (e: Exception) {
emptyList()
} ?: emptyList()
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
gson.fromJson(reactionsJson, reactionsTypeToken)
} catch (e: Exception) {
try {
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
val map: Map<String, Int> = 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"
}
}

View File

@@ -6,7 +6,7 @@ import chats.domain.model.Message
interface ChatRepository {
suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
suspend fun getMessages(chatId: String): List<Message>
suspend fun getMessages(chatId: String, cursor: String? = null, limit: Int? = null): List<Message>
suspend fun sendMessage(
chatId: String,
content: String?,

View File

@@ -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<MessageListItem>()
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<chats.domain.model.Message>()
// Собираем все сообщения за этот локальный день
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()
}

View File

@@ -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<Message>) {
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) }
}

View File

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

View File

@@ -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<Media>, 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) {

View File

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

View File

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