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.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 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 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 ) @HiltViewModel class ChatDetailViewModel @Inject constructor( private val repository: ChatRepository, private val signalrClient: ChatHubClient, private val serverConfig: ServerConfig, private val tokenManager: TokenManager, @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 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) { currentChatId = chatId // Ensure SignalR is connected val token = tokenManager.getToken() if (token != null) { val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api") signalrClient.connect(baseUrl, token) } loadChatInfo(chatId) observeMessages(chatId) observeSignalREvents() // 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) } fun refreshMessages(chatId: String) { viewModelScope.launch { repository.getMessages(chatId) } } 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() { signalrClient.events .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 } } .onEach { event -> when (event) { is ChatEvent.NewMessage -> { val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val domainMsg = event.message.toDomain(baseUrl) viewModelScope.launch { repository.saveMessage(domainMsg) } } is ChatEvent.ReactionUpdated -> { updateMessageReaction(event.messageId, event.userId, event.emoji) } 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. } else -> Unit } } .launchIn(viewModelScope) } fun markAsRead() { val chatId = currentChatId ?: return val messages = _state.value.messages if (messages.isEmpty()) return val lastMessage = messages.last() viewModelScope.launch { try { repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId) } catch (e: Exception) { // Ignore } } } fun sendMessage(text: String) { val chatId = currentChatId ?: return val pending = _state.value.pendingAttachments if (text.isBlank() && pending.isEmpty()) return 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 = text, type = if (attachmentRequests != null) "media" else "text", attachments = attachmentRequests ) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) } catch (e: Exception) { repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage, isUploading = false) } } } } 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 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) } } } } }