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.remote.signalr.ConnectionStatus import chats.data.repository.toDomain import chats.domain.model.Message import chats.domain.repository.ChatRepository import core.network.NetworkManager 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 private const val TAG = "ChatDetailViewModel" 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 = "", val replyingMessage: Message? = null, val editingMessage: Message? = null, val forwardingMessages: List = emptyList(), val availableChatsToForward: List = emptyList(), val selectedMessageIds: Set = emptySet(), val pinnedMessages: List = emptyList() ) @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, private val networkManager: NetworkManager, @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) observeSignalRStatus(chatId) observeSignalREvents(chatId) observeNetworkStatus(chatId) // Initial sync from network refreshMessages(chatId) } private fun observeNetworkStatus(chatId: String) { // При восстановлении сети обновляем сообщения networkManager.isOnline .filter { it } // Только переход в онлайн .distinctUntilChanged() .onEach { android.util.Log.d(TAG, "Network restored in chat detail, refreshing messages") kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться refreshMessages(chatId) } .launchIn(viewModelScope) } private fun observeSignalRStatus(chatId: String) { // При переподключении SignalR обновляем сообщения signalrClient.status .filter { it == ConnectionStatus.CONNECTED } .distinctUntilChanged() .onEach { android.util.Log.d(TAG, "SignalR connected, refreshing messages for chat $chatId") refreshMessages(chatId) } .launchIn(viewModelScope) } 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 { try { // Сначала пытаемся загрузить из сети val messages = repository.getMessages(chatId) if (messages.isNotEmpty()) { updateMessages(messages) return@launch } } catch (e: Exception) { android.util.Log.d(TAG, "Network load failed, trying cache") } // Если сеть не доступна или пуста - загружаем из Room try { val cachedMessages = repository.getMessagesFlow(chatId).first() android.util.Log.d(TAG, "Loaded ${cachedMessages.size} messages from cache") updateMessages(cachedMessages) } catch (e: Exception) { android.util.Log.e(TAG, "Cache load failed", e) _state.update { it.copy(isLoading = false, messages = emptyList()) } } } } 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 { // Пробуем загрузить из API 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) } android.util.Log.d(TAG, "Loaded chat info from API: ${c.name}") return@launch } } catch (e: Exception) { android.util.Log.d(TAG, "API load failed, will use messages for title") } // Если API недоступно - берём имя из сообщений (для личных чатов) try { val cachedMessages = repository.getMessagesFlow(chatId).first() val otherUserMessage = cachedMessages.firstOrNull { it.senderId != getCurrentUserId() } otherUserMessage?.let { msg -> _state.update { it.copy(chatName = msg.senderName, chatAvatar = msg.senderAvatar) } android.util.Log.d(TAG, "Loaded chat title from messages: ${msg.senderName}") } } catch (e: Exception) { android.util.Log.e(TAG, "Failed to load chat title from messages", e) } } } 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) } } // For handling reaction removed event private fun removeMessageReaction(messageId: String, userId: String, emoji: String) { _state.update { s -> val updatedMessages = s.messages.map { msg -> if (msg.id == messageId) { val currentReactions = msg.reactions.toMutableMap() val count = currentReactions[emoji] ?: 0 if (count > 1) { currentReactions[emoji] = count - 1 } else { currentReactions.remove(emoji) } 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 -> { if (event.isRemoved) { removeMessageReaction(event.messageId, event.userId, event.emoji) } else { updateMessageReaction(event.messageId, event.userId, event.emoji) } } is ChatEvent.MessageDeleted -> { _state.update { currentState -> currentState.copy(messages = currentState.messages.filter { it.id != event.messageId }) } } is ChatEvent.MessageEdited -> { _state.update { currentState -> val updated = currentState.messages.map { msg -> if (msg.id == event.messageId) msg.copy(content = event.content) else msg } currentState.copy(messages = updated) } } is ChatEvent.UserTyping -> { // Игнорируем свои же typing события if (event.userId == getCurrentUserId()) { android.util.Log.d("ChatDetailVM", "Ignoring own typing event") return@onEach } _state.update { it.copy(isTyping = true) } typingTimerJob?.cancel() typingTimerJob = viewModelScope.launch { delay(3000) _state.update { it.copy(isTyping = false) } } } is ChatEvent.UserStoppedTyping -> { // Игнорируем свои же события if (event.userId == getCurrentUserId()) { android.util.Log.d("ChatDetailVM", "Ignoring own stopped typing event") return@onEach } _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) } } is ChatEvent.MessagePinned -> { val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") _state.update { s -> val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl) s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id }) } } is ChatEvent.MessageUnpinned -> { _state.update { s -> s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId }) } } 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 onReply(message: Message) { _state.update { it.copy(replyingMessage = message) } } fun forwardMessages(targetChatId: String, messages: List) { viewModelScope.launch { messages.forEach { msg -> repository.sendMessage( chatId = targetChatId, content = msg.content, type = msg.mediaType.name.lowercase(), forwardedFromId = msg.senderId ) } } } fun onForward(message: Message) { _state.update { it.copy(forwardingMessages = listOf(message)) } loadChatsForForwarding() } fun loadChatsForForwarding() { viewModelScope.launch { try { val chats = repository.getChats() _state.update { it.copy(availableChatsToForward = chats) } } catch (e: Exception) { // Ignore } } } fun cancelForwarding() { _state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) } } fun onForwardSelectedMessages() { val selectedIds = _state.value.selectedMessageIds val messages = _state.value.messages.filter { selectedIds.contains(it.id) } _state.update { it.copy(forwardingMessages = messages) } loadChatsForForwarding() } fun toggleSelection(messageId: String) { _state.update { s -> val newSelection = if (s.selectedMessageIds.contains(messageId)) { s.selectedMessageIds - messageId } else { s.selectedMessageIds + messageId } s.copy(selectedMessageIds = newSelection) } } fun clearSelection() { _state.update { it.copy(selectedMessageIds = emptySet()) } } fun cancelReply() { _state.update { it.copy(replyingMessage = null) } } fun cancelEdit() { _state.update { it.copy(editingMessage = null, inputText = "") } } fun deleteMessage(message: Message, forEveryone: Boolean) { viewModelScope.launch { try { repository.deleteMessage(message.id, forEveryone) // Local update if needed (will also come via SignalR for everyone, but forMe might need local only update) _state.update { currentState -> currentState.copy(messages = currentState.messages.filter { it.id != message.id }) } } catch (e: Exception) { _state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") } } } } fun onEdit(message: Message) { _state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) } } fun pinMessage(messageId: String) { val chatId = currentChatId ?: return signalrClient.pinMessage(messageId, chatId) } fun unpinMessage(messageId: String) { val chatId = currentChatId ?: return signalrClient.unpinMessage(messageId, chatId) } fun onPin(message: Message) { pinMessage(message.id) } fun sendMessage(onFail: (String) -> Unit = {}) { val chatId = currentChatId ?: return val text = _state.value.inputText val pending = _state.value.pendingAttachments val replyToId = _state.value.replyingMessage?.id val editingMsg = _state.value.editingMessage if (text.isBlank() && pending.isEmpty()) return // Handle Edit if (editingMsg != null) { _state.update { it.copy(inputText = "", editingMessage = null) } viewModelScope.launch { try { val updated = repository.editMessage(editingMsg.id, text) _state.update { currentState -> val updatedList = currentState.messages.map { if (it.id == updated.id) updated else it } currentState.copy(messages = updatedList) } } catch (e: Exception) { _state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") } onFail(text) } } return } // Clear input immediately to avoid double clicks and ensure UI experience _state.update { it.copy(inputText = "", replyingMessage = null) } 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, replyToId = replyToId ) 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) { val chatId = currentChatId ?: return // We rely on SignalR event to update the count to avoid double counting // especially since domain Message doesn't track user IDs for reactions yet. signalrClient.addReaction(messageId, chatId, emoji) } fun sendVoiceMessage(file: File) { val chatId = currentChatId ?: return val replyToId = _state.value.replyingMessage?.id _state.update { it.copy(replyingMessage = null) } 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), replyToId = replyToId ) 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 replyToId = _state.value.replyingMessage?.id _state.update { it.copy(replyingMessage = null) } 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), replyToId) 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) } } } } }