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

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
Binary file not shown.
Binary file not shown.
+12 -12
View File
@@ -10,17 +10,17 @@ data class UserBasicDto(
) )
data class MessageDto( data class MessageDto(
@SerializedName("id") val id: String, @SerializedName("id", alternate = ["Id"]) val id: String,
@SerializedName("chatId") val chatId: String? = null, @SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
@SerializedName("senderId") val senderId: String? = null, @SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
@SerializedName("content") val content: String? = null, @SerializedName("content", alternate = ["Content"]) val content: String? = null,
@SerializedName("type") val type: String? = null, @SerializedName("type", alternate = ["Type"]) val type: String? = null,
@SerializedName("sequenceId") val sequenceId: Int? = null, @SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
@SerializedName("createdAt") val createdAt: String? = null, @SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
@SerializedName("sender") val sender: UserBasicDto? = null, @SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
@SerializedName("media") val media: List<MediaItemDto> = emptyList(), @SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions") val reactions: List<ReactionDto>? = emptyList(), @SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo") val replyTo: MessageDto? = null @SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null
) )
data class ReactionDto( data class ReactionDto(
@@ -43,7 +43,7 @@ data class ChatDto(
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("name") val name: String? = null, @SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: 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("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList() @SerializedName("members") val members: List<ChatMemberDto> = emptyList()
) )
@@ -22,12 +22,30 @@ data class ReadMessagesRequest(
val lastReadSequenceId: Int 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 } enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton @Singleton
class ChatHubClient @Inject constructor() { class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null 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() val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED) private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -77,13 +95,17 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.NewMessage(message)) _events.tryEmit(ChatEvent.NewMessage(message))
}, MessageDto::class.java) }, MessageDto::class.java)
conn.on("messages_read", { chatId: String, userId: String, lastReadSequenceId: Int -> conn.on("messages_read", { data: MessagesReadEvent ->
_events.tryEmit(ChatEvent.MessagesRead(chatId, userId, lastReadSequenceId)) _events.tryEmit(ChatEvent.MessagesRead(
}, String::class.java, String::class.java, Int::class.java) data.effectiveChatId,
data.effectiveUserId,
data.effectiveLastReadSequenceId
))
}, MessagesReadEvent::class.java)
conn.on("user_typing", { chatId: String, userId: String -> conn.on("user_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserTyping(chatId, userId)) _events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, String::class.java, String::class.java) }, ReactionEvent::class.java)
conn.on("user_online", { userId: String -> conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId)) _events.tryEmit(ChatEvent.UserOnline(userId))
@@ -93,14 +115,23 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.NewChat(chat)) _events.tryEmit(ChatEvent.NewChat(chat))
}, ChatDto::class.java) }, ChatDto::class.java)
conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String -> conn.on("reaction_added", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji)) _events.tryEmit(ChatEvent.ReactionUpdated(
}, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java) data.messageId ?: "",
data.chatId ?: "",
data.userId ?: "",
data.emoji ?: ""
))
}, ReactionEvent::class.java)
conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String -> conn.on("reaction_removed", { data: ReactionEvent ->
// Using ReactionUpdated with empty emoji to signal removal or just a specific removal event _events.tryEmit(ChatEvent.ReactionUpdated(
_events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, "")) data.messageId ?: "",
}, String::class.java, String::class.java, String::class.java, String::class.java) data.chatId ?: "",
data.userId ?: "",
"" // empty emoji signals removal
))
}, ReactionEvent::class.java)
// WebRTC Signaling Handlers // WebRTC Signaling Handlers
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String -> conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
@@ -128,7 +159,26 @@ class ChatHubClient @Inject constructor() {
fun readMessages(request: ReadMessagesRequest) { fun readMessages(request: ReadMessagesRequest) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { 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")
}
} }
} }
} }
@@ -11,6 +11,7 @@ import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.network.ServerConfig import core.network.ServerConfig
import chats.data.remote.signalr.ReadMessagesRequest
import core.security.TokenManager import core.security.TokenManager
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody 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 baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val currentUserId = tokenManager.getUserId() ?: ""
return try { return try {
val messages = api.getMessages(chatId) val chats = api.getChats()
// Save to DB val chatDto = chats.find { it.id == chatId }
messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) }) val unreadCount = chatDto?.unreadCount ?: 0
messages.map { it.toDomain(baseUrl) }
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) { } catch (e: Exception) {
// If network fails, caller should ideally use the Flow from DB
emptyList() emptyList()
} }
} }
@@ -65,7 +78,8 @@ class ChatRepositoryImpl @Inject constructor(
attachments = attachments attachments = attachments
) )
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") 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) { 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) { override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
val request = chats.data.remote.signalr.ReadMessagesRequest( try {
chatId = chatId, android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
lastReadMessageId = lastMessageId, hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
lastReadSequenceId = lastReadSequenceId } catch (e: Exception) {
) android.util.Log.e("ChatRepo", "Error marking messages as read", e)
hubClient.readMessages(request) }
// Обновляем локальную БД
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
} }
override suspend fun saveMessage(message: Message) { 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) { override suspend fun deleteLocalMessage(messageId: String) {
@@ -119,168 +135,3 @@ class ChatRepositoryImpl @Inject constructor(
return api.getGifCategories().data.categories 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"
}
}
@@ -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"
}
}
@@ -6,7 +6,7 @@ import chats.domain.model.Message
interface ChatRepository { interface ChatRepository {
suspend fun getChats(): List<Chat> suspend fun getChats(): List<Chat>
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> 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( suspend fun sendMessage(
chatId: String, chatId: String,
content: String?, content: String?,
@@ -22,12 +22,18 @@ import kotlin.math.roundToInt
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp 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.MediaPicker
import chats.presentation.components.MessageBubble import chats.presentation.components.MessageBubble
import core.presentation.components.AppAvatar import core.presentation.components.AppAvatar
@@ -55,6 +61,11 @@ fun ChatDetailScreen(
viewModel: ChatDetailViewModel, viewModel: ChatDetailViewModel,
onBack: () -> Unit onBack: () -> Unit
) { ) {
DisposableEffect(Unit) {
onDispose {
viewModel.clearChatId()
}
}
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -120,27 +131,97 @@ fun ChatDetailScreen(
viewModel.setChatId(chatId) viewModel.setChatId(chatId)
} }
// Автопрокрутка к первому непрочитанному или к самому низу
LaunchedEffect(state.initialScrollIndex) { val listItems = remember(state.messages) {
state.initialScrollIndex?.let { index -> val items = mutableListOf<MessageListItem>()
if (index < state.messages.size) { if (state.messages.isEmpty()) return@remember items
listState.scrollToItem(index)
// Группируем от новых к старым (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) { LaunchedEffect(listState) {
if (state.messages.isNotEmpty()) { snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2 .collect { lastVisibleIndex ->
if (isAtBottom || state.initialScrollIndex == null) { if (lastVisibleIndex != null && lastVisibleIndex >= listItems.size - 10 && listItems.size >= 15) {
listState.animateScrollToItem(state.messages.size - 1) android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, size ${listItems.size}")
} viewModel.loadMoreMessages()
}
// Если пришли новые сообщения и мы их видим — помечаем как прочитанные
if (isAtBottom) {
viewModel.markAsRead()
} }
}
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( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
reverseLayout = true,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -207,34 +289,76 @@ fun ChatDetailScreen(
msg.media.map { it to msg.id } msg.media.map { it to msg.id }
}.reversed() }.reversed()
items(state.messages, key = { it.id }) { message -> items(listItems, key = {
MessageBubble( when(it) {
message = message, is MessageListItem.MessageItem -> it.message.id
isCurrentUser = message.senderId == viewModel.getCurrentUserId(), is MessageListItem.DateHeader -> "date_${it.date}"
autoPlay = message.id == autoPlayingMessageId, MessageListItem.UnreadSeparator -> "unread_separator"
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, }
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) }, }) { item ->
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }, when(item) {
onMediaClick = { clickedMedia -> is MessageListItem.MessageItem -> {
val isMedia = clickedMedia.type.startsWith("image") || MessageBubble(
clickedMedia.type.startsWith("video") || message = item.message,
clickedMedia.type.contains("gif") isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(),
autoPlay = item.message.id == autoPlayingMessageId,
if (isMedia) { initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url } onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) },
selectedMediaList = allChatMedia.map { it.first } onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) },
initialMediaIndex = if (initialIndex != -1) initialIndex else 0 onMediaClick = { clickedMedia ->
} else { val isMedia = clickedMedia.type.startsWith("image") ||
// Прямое открытие документов во внешних приложениях clickedMedia.type.startsWith("video") ||
try { clickedMedia.type.contains("gif")
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
context.startActivity(intent) if (isMedia) {
} catch (e: Exception) { 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) { if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) 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), 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 -> decorationBox = { innerTextField ->
if (textInput.isEmpty()) { if (textInput.isEmpty()) {
Text( Text(
@@ -443,8 +648,11 @@ fun ChatDetailScreen(
Surface( Surface(
onClick = { onClick = {
if (!showMic) { if (!showMic) {
viewModel.sendMessage(textInput) viewModel.sendMessage(textInput, onFail = { failedText ->
textInput = failedText
})
textInput = "" textInput = ""
isEmojiPickerVisible = false
} }
}, },
shape = RoundedCornerShape(16.dp), 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()
}
@@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import chats.data.remote.signalr.ChatEvent import chats.data.remote.signalr.ChatEvent
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.toDomain
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import chats.data.repository.toDomain
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -26,6 +26,7 @@ data class ChatDetailState(
val chatName: String? = null, val chatName: String? = null,
val chatAvatar: String? = null, val chatAvatar: String? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val isTyping: Boolean = false, val isTyping: Boolean = false,
val typingUser: String? = null, val typingUser: String? = null,
val error: String? = null, val error: String? = null,
@@ -56,6 +57,7 @@ class ChatDetailViewModel @Inject constructor(
private var currentChatId: String? = null private var currentChatId: String? = null
private var typingTimerJob: Job? = null private var typingTimerJob: Job? = null
private var signalrEventsJob: Job? = null
private var lastTypingSentTime: Long = 0 private var lastTypingSentTime: Long = 0
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
@@ -83,43 +85,73 @@ class ChatDetailViewModel @Inject constructor(
} }
fun setChatId(chatId: String) { fun setChatId(chatId: String) {
if (currentChatId == chatId) return
currentChatId = chatId 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() val token = tokenManager.getToken()
if (token != null) { if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api") val baseUrl = serverConfig.getBaseUrl()
signalrClient.connect(baseUrl, token) signalrClient.connect(baseUrl, token)
signalrClient.joinChat(chatId)
} }
loadChatInfo(chatId) loadChatInfo(chatId)
observeMessages(chatId) observeSignalREvents(chatId)
observeSignalREvents()
// Initial sync from network // Initial sync from network
refreshMessages(chatId) refreshMessages(chatId)
} }
private fun observeMessages(chatId: String) { private fun updateMessages(messages: List<Message>) {
repository.getMessagesFlow(chatId) val sortedMessages = messages.sortedByDescending { it.sequenceId }
.onEach { messages -> _state.update { it.copy(
_state.update { it.copy(messages = messages) } messages = sortedMessages,
// Calculate scroll index if not set isLoading = false,
if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) { initialScrollIndex = 0 // In reverse layout, 0 is the bottom
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) }
fun onInitialScrollDone() {
// Automark as read _state.update { it.copy(initialScrollIndex = -1) }
markAsRead()
}
}
.launchIn(viewModelScope)
} }
fun refreshMessages(chatId: String) { fun refreshMessages(chatId: String) {
viewModelScope.launch { 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() { private fun observeSignalREvents(chatId: String) {
signalrClient.events signalrEventsJob?.cancel()
signalrEventsJob = signalrClient.events
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
.filter { event -> .filter { event ->
when(event) { val eventChatId = when(event) {
is ChatEvent.NewMessage -> event.message.chatId == currentChatId is ChatEvent.NewMessage -> event.message.chatId
is ChatEvent.ReactionUpdated -> event.chatId == currentChatId is ChatEvent.ReactionUpdated -> event.chatId
is ChatEvent.UserTyping -> event.chatId == currentChatId is ChatEvent.UserTyping -> event.chatId
else -> false 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 -> .onEach { event ->
when (event) { when (event) {
is ChatEvent.NewMessage -> { 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 baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val domainMsg = event.message.toDomain(baseUrl) val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
viewModelScope.launch {
repository.saveMessage(domainMsg) _state.update { currentState ->
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
} }
} }
is ChatEvent.ReactionUpdated -> { is ChatEvent.ReactionUpdated -> {
@@ -174,8 +220,21 @@ class ChatDetailViewModel @Inject constructor(
} }
is ChatEvent.UserTyping -> { is ChatEvent.UserTyping -> {
_state.update { it.copy(isTyping = true) } _state.update { it.copy(isTyping = true) }
// Reset typing status after some delay would be better, typingTimerJob?.cancel()
// but usually server sends stopped_typing event. 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 else -> Unit
} }
@@ -183,21 +242,42 @@ class ChatDetailViewModel @Inject constructor(
.launchIn(viewModelScope) .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() { fun markAsRead() {
val chatId = currentChatId ?: return val chatId = currentChatId ?: return
val messages = _state.value.messages val messages = _state.value.messages
if (messages.isEmpty()) return if (messages.isEmpty()) return
val lastMessage = messages.last() // В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
val currentUserId = getCurrentUserId()
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
viewModelScope.launch { viewModelScope.launch {
try { 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) { } catch (e: Exception) {
// Ignore // Ignore
} }
} }
} }
fun sendMessage(text: String) {
fun sendMessage(text: String, onFail: (String) -> Unit = {}) {
val chatId = currentChatId ?: return val chatId = currentChatId ?: return
val pending = _state.value.pendingAttachments val pending = _state.value.pendingAttachments
if (text.isBlank() && pending.isEmpty()) return if (text.isBlank() && pending.isEmpty()) return
@@ -259,15 +339,18 @@ class ChatDetailViewModel @Inject constructor(
val sentMessage = repository.sendMessage( val sentMessage = repository.sendMessage(
chatId = chatId, chatId = chatId,
content = text, content = if (text.isBlank()) null else text,
type = if (attachmentRequests != null) "media" else "text", type = if (attachmentRequests != null) "media" else "text",
attachments = attachmentRequests attachments = attachmentRequests
) )
repository.deleteLocalMessage(tempId) repository.deleteLocalMessage(tempId)
repository.saveMessage(sentMessage) repository.saveMessage(sentMessage)
// Clear attachments on success
_state.update { it.copy(pendingAttachments = emptyList()) }
} catch (e: Exception) { } catch (e: Exception) {
repository.deleteLocalMessage(tempId) repository.deleteLocalMessage(tempId)
_state.update { it.copy(error = e.localizedMessage, isUploading = false) } _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) { fun removePendingAttachment(file: File) {
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) } _state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
} }
@@ -108,11 +108,24 @@ class ChatListViewModel @Inject constructor(
_state.update { currentState -> _state.update { currentState ->
val updatedChats = currentState.chats.map { chat -> 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 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( chat.copy(
lastMessage = event.message.toDomain(baseUrl), lastMessage = lastMsgDomain,
unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1 unreadCount = newCount
) )
} else chat } else chat
} }
@@ -7,6 +7,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -157,6 +158,25 @@ fun MessageBubble(
Spacer(modifier = Modifier.height(4.dp)) 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) // Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) { if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size val mediaCount = message.media.size
@@ -370,8 +390,8 @@ fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int
val columns = when { val columns = when {
mediaList.size == 1 -> 1 mediaList.size == 1 -> 1
mediaList.size % 3 == 0 -> 3 mediaList.size <= 4 -> 2
else -> 2 else -> 3
} }
Column(modifier = modifier) { Column(modifier = modifier) {
@@ -33,6 +33,9 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :messageId") @Query("DELETE FROM messages WHERE id = :messageId")
suspend fun deleteMessage(messageId: String) 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) @Database(entities = [MessageEntity::class], version = 1)
+5 -1
View File
@@ -11,6 +11,7 @@ import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -24,9 +25,12 @@ object NetworkModule {
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
): OkHttpClient { ): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY level = HttpLoggingInterceptor.Level.HEADERS
} }
return OkHttpClient.Builder() return OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(300, TimeUnit.SECONDS)
.addInterceptor(logging) .addInterceptor(logging)
.addInterceptor(dynamicBaseUrlInterceptor) .addInterceptor(dynamicBaseUrlInterceptor)
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)