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

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