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 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 ) @HiltViewModel class ChatDetailViewModel @Inject constructor( private val repository: ChatRepository, private val signalrClient: ChatHubClient, private val serverConfig: ServerConfig, private val tokenManager: TokenManager ) : 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 init { val config = serverConfig.getServerConfig() _state.update { it.copy( canCall = config.features.calls, maxFileSize = config.limits.maxFileSize ) } } 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 if (text.isBlank()) return val tempId = "temp_${System.currentTimeMillis()}" val userId = getCurrentUserId() val tempMessage = Message( id = tempId, chatId = chatId, senderId = userId, content = text, createdAt = java.util.Date().toString(), mediaType = chats.domain.model.MediaType.TEXT, media = emptyList(), senderName = "Вы", senderAvatar = null, reactions = emptyMap(), isRead = false, sequenceId = 0 ) viewModelScope.launch { repository.saveMessage(tempMessage) } viewModelScope.launch { try { val sentMessage = repository.sendMessage(chatId, text) repository.deleteLocalMessage(tempId) repository.saveMessage(sentMessage) } catch (e: Exception) { repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage) } } } } fun addReaction(messageId: String, emoji: String) { viewModelScope.launch { try { repository.addReaction(messageId, emoji) } catch (e: Exception) { // Ignore } } } 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) } } } } }