Рабочие счетчики и переработка чата
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user