package chats.presentation.chat_detail 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 core.network.ServerConfig import core.security.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.io.File import javax.inject.Inject import core.utils.copyUriToFile import core.utils.ImageUtils import chats.data.remote.api.KlipyGifDto data class ChatDetailState( val messages: List = emptyList(), 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, val canCall: Boolean = true, val maxFileSize: Long = 100 * 1024 * 1024, val trendingGifs: List = emptyList(), val searchedGifs: List = emptyList(), val recentGifs: List = emptyList(), val gifCategories: List = emptyList(), val isGifsLoading: Boolean = false, val initialScrollIndex: Int? = null, val pendingAttachments: List = emptyList(), val isUploading: Boolean = false, val isCompressionEnabled: Boolean = true, val inputText: String = "" ) @HiltViewModel class ChatDetailViewModel @Inject constructor( private val repository: ChatRepository, private val signalrClient: ChatHubClient, private val serverConfig: ServerConfig, private val tokenManager: TokenManager, private val activeChatTracker: core.notifications.data.ActiveChatTracker, private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver, @dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context ) : ViewModel() { private val _state = MutableStateFlow(ChatDetailState()) val state: StateFlow = _state.asStateFlow() 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) init { val config = serverConfig.getServerConfig() val savedCompression = prefs.getBoolean("compression_enabled", true) _state.update { it.copy( canCall = config.features.calls, maxFileSize = config.limits.maxFileSize, isCompressionEnabled = savedCompression ) } } fun toggleCompression() { _state.update { currentState -> val newValue = !currentState.isCompressionEnabled prefs.edit().putBoolean("compression_enabled", newValue).apply() currentState.copy(isCompressionEnabled = newValue) } } fun getCurrentUserId(): String { return tokenManager.getUserId() ?: "" } fun setChatId(chatId: String) { if (currentChatId == chatId) return currentChatId = chatId activeChatTracker.setChatId(chatId) _state.update { it.copy( messages = emptyList(), isLoading = true, initialScrollIndex = null ) } // Ensure SignalR is connected and join the chat room val token = tokenManager.getToken() val baseUrl = serverConfig.getBaseUrl() if (token != null && baseUrl.isNotBlank()) { signalrClient.connect(baseUrl.removeSuffix("/api/"), token) signalrClient.joinChat(chatId) } loadChatInfo(chatId) observeSignalREvents(chatId) // Initial sync from network refreshMessages(chatId) } private fun updateMessages(messages: List) { 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 { 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) } } } } private fun loadChatInfo(chatId: String) { viewModelScope.launch { try { val chats = repository.getChats() val chat = chats.find { it.id == chatId } chat?.let { c -> _state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) } } } catch (e: Exception) { // Ignore info load error } } } private fun updateMessageReaction(messageId: String, userId: String, emoji: String) { _state.update { s -> val updatedMessages = s.messages.map { msg -> if (msg.id == messageId) { val currentReactions = msg.reactions.toMutableMap() currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1 msg.copy(reactions = currentReactions) } else msg } s.copy(messages = updatedMessages) } } 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 -> 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(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 -> { updateMessageReaction(event.messageId, event.userId, event.emoji) } is ChatEvent.UserTyping -> { _state.update { it.copy(isTyping = true) } typingTimerJob?.cancel() typingTimerJob = viewModelScope.launch { delay(3000) _state.update { it.copy(isTyping = false) } } } is ChatEvent.UserStoppedTyping -> { _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 } } .launchIn(viewModelScope) } fun clearChatId() { android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId") signalrEventsJob?.cancel() signalrEventsJob = null currentChatId = null activeChatTracker.setChatId(null) _state.update { it.copy(messages = emptyList(), isLoading = false) } } fun markAsRead() { val chatId = currentChatId ?: return val messages = _state.value.messages if (messages.isEmpty()) return // В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника val currentUserId = getCurrentUserId() val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return viewModelScope.launch { try { // Мгновенно обновляем в памяти для "галочек" _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) signalrNotificationObserver.refresh() } catch (e: Exception) { // Ignore } } } fun onInputTextChanged(text: String) { _state.update { it.copy(inputText = text) } val chatId = currentChatId ?: return // Отправляем индикатор набора текста val now = System.currentTimeMillis() if (now - lastTypingSentTime > 1000) { signalrClient.sendTypingIndicator(chatId) lastTypingSentTime = now // Отправляем "остановился печатать" через 3 секунды typingTimerJob?.cancel() typingTimerJob = viewModelScope.launch { delay(3000) signalrClient.sendUserStoppedTyping(chatId) } } } fun sendMessage(onFail: (String) -> Unit = {}) { val chatId = currentChatId ?: return val text = _state.value.inputText val pending = _state.value.pendingAttachments if (text.isBlank() && pending.isEmpty()) return // Clear input immediately to avoid double clicks and ensure UI experience _state.update { it.copy(inputText = "") } val tempId = "temp_${System.currentTimeMillis()}" val userId = getCurrentUserId() // Determine mediaType based on attachments val mediaType = when { pending.isEmpty() -> chats.domain.model.MediaType.TEXT pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO else -> chats.domain.model.MediaType.TEXT } val tempMessage = Message( id = tempId, chatId = chatId, senderId = userId, content = if (text.isBlank()) null else text, createdAt = java.util.Date().toString(), mediaType = mediaType, media = emptyList(), senderName = "Вы", senderAvatar = null, reactions = emptyMap(), isRead = false, sequenceId = 0 ) viewModelScope.launch { repository.saveMessage(tempMessage) } viewModelScope.launch { try { // Upload attachments if any val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) { _state.update { it.copy(isUploading = true) } val requests = _state.value.pendingAttachments.map { file -> val url = repository.uploadMedia(file) chats.data.remote.api.AttachmentRequest( type = when { file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image" file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video" file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio" else -> "file" }, url = url, fileName = file.name, fileSize = file.length() ) } _state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) } requests } else { null } val sentMessage = repository.sendMessage( chatId = chatId, 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) } } } fun addReaction(messageId: String, emoji: String) { viewModelScope.launch { try { repository.addReaction(messageId, emoji) } catch (e: Exception) { // Ignore } } } fun sendVoiceMessage(file: File) { val chatId = currentChatId ?: return val tempId = "temp_voice_${System.currentTimeMillis()}" val userId = getCurrentUserId() val tempMessage = Message( id = tempId, chatId = chatId, senderId = userId, content = null, createdAt = java.util.Date().toString(), mediaType = chats.domain.model.MediaType.AUDIO, media = emptyList(), senderName = "Вы", reactions = emptyMap(), isRead = false, sequenceId = 0 ) viewModelScope.launch { repository.saveMessage(tempMessage) } viewModelScope.launch { try { // 1. Upload the audio file val url = repository.uploadMedia(file) // 2. Send the message with the attachment val attachment = chats.data.remote.api.AttachmentRequest( type = "voice", url = url, fileName = file.name, fileSize = file.length() ) val sentMessage = repository.sendMessage( chatId = chatId, content = null, type = "audio", attachments = listOf(attachment) ) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) } catch (e: Exception) { repository.deleteLocalMessage(tempId) _state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") } } } } fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) { viewModelScope.launch { val mimeType = context.contentResolver.getType(uri) ?: "" val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) { ImageUtils.compressImage(context, uri) } else { copyUriToFile(context, uri) } file?.let { f -> _state.update { it.copy(pendingAttachments = it.pendingAttachments + f) } } } } 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) } } fun uploadMedia(file: File) { if (file.length() > _state.value.maxFileSize) { _state.update { it.copy(error = "File too large") } return } viewModelScope.launch { try { val url = repository.uploadMedia(file) // After upload, we might want to send a message with this media // For now, let's just log it or handle as per app requirements } catch (e: Exception) { _state.update { it.copy(error = e.localizedMessage) } } } } fun loadTrendingGifs() { if (_state.value.trendingGifs.isNotEmpty()) return viewModelScope.launch { _state.update { it.copy(isGifsLoading = true) } try { val gifs = repository.getTrendingGifs(0) _state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) } } catch (e: Exception) { _state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") } } } } fun loadGifCategories() { if (_state.value.gifCategories.isNotEmpty()) return viewModelScope.launch { try { val categories = repository.getGifCategories() _state.update { it.copy(gifCategories = categories) } } catch (e: Exception) { // Ignore failure } } } private var searchJob: Job? = null fun searchGifs(query: String) { searchJob?.cancel() if (query.isBlank()) { _state.update { it.copy(searchedGifs = emptyList()) } return } searchJob = viewModelScope.launch { delay(500) _state.update { it.copy(isGifsLoading = true) } try { val gifs = repository.searchGifs(query, 0) _state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) } } catch (e: Exception) { _state.update { it.copy(isGifsLoading = false) } } } } fun sendGif(url: String) { val chatId = currentChatId ?: return val tempId = "temp_gif_${System.currentTimeMillis()}" val userId = getCurrentUserId() val tempMessage = Message( id = tempId, chatId = chatId, senderId = userId, content = url, createdAt = java.util.Date().toString(), mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")), senderName = "Вы", senderAvatar = null, reactions = emptyMap(), isRead = false, sequenceId = 0 ) viewModelScope.launch { repository.saveMessage(tempMessage) } viewModelScope.launch { try { val attachment = chats.data.remote.api.AttachmentRequest( type = "image", url = url, fileName = "gif.gif", fileSize = 0 ) val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment)) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) // Add to recent val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs val selectedGif = allGifs.find { gif -> val gifUrl = gif.files?.get("hd")?.get("gif")?.url ?: gif.files?.get("sd")?.get("gif")?.url ?: gif.file?.get("hd")?.get("gif")?.url ?: gif.file?.get("sd")?.get("gif")?.url ?: gif.media_formats?.get("gif")?.url ?: gif.images?.original?.url gifUrl == url } selectedGif?.let { gif -> val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20) _state.update { it.copy(recentGifs = newList) } } } catch (e: Exception) { repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage) } } } } }