From 6961ac7240929ea738e9522329254d8150f6dbc3 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 14 May 2026 18:14:10 +0300 Subject: [PATCH] refactor: split message actions and parsers --- .../meloda/fast/domain/LongPollEventParser.kt | 558 ++++++++++++++++ .../fast/domain/LongPollUpdatesParser.kt | 556 +--------------- .../MessagesHistoryMessageActions.kt | 520 +++++++++++++++ .../MessagesHistoryViewModelImpl.kt | 594 +----------------- 4 files changed, 1122 insertions(+), 1106 deletions(-) create mode 100644 core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt create mode 100644 feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryMessageActions.kt diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt new file mode 100644 index 00000000..d2db1a87 --- /dev/null +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollEventParser.kt @@ -0,0 +1,558 @@ +package dev.meloda.fast.domain + +import android.util.Log +import dev.meloda.fast.common.VkConstants +import dev.meloda.fast.common.extensions.asInt +import dev.meloda.fast.common.extensions.asLong +import dev.meloda.fast.common.extensions.listenValue +import dev.meloda.fast.common.extensions.toList +import dev.meloda.fast.data.UserConfig +import dev.meloda.fast.data.processState +import dev.meloda.fast.model.ApiEvent +import dev.meloda.fast.model.ConvoFlags +import dev.meloda.fast.model.InteractionType +import dev.meloda.fast.model.LongPollEvent +import dev.meloda.fast.model.LongPollParsedEvent +import dev.meloda.fast.model.MessageFlags +import dev.meloda.fast.model.api.domain.VkConvo +import dev.meloda.fast.model.api.domain.VkMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal class LongPollEventParser( + private val coroutineScope: CoroutineScope, + private val convoUseCase: ConvoUseCase, + private val messagesUseCase: MessagesUseCase, + private val dispatch: (LongPollEvent, LongPollParsedEvent) -> Unit, + private val dispatchAll: (LongPollEvent, List) -> Unit +) { + fun parseNextUpdate(event: List) { + val eventId = event.first().asInt() + + when (val eventType = ApiEvent.parseOrNull(eventId)) { + null -> Log.d("LongPollEventParser", "parseNextUpdate: unknownEvent: $event") + + ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) + ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) + ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) + ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event) + ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event) + ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event) + ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event) + ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event) + ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event) + ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event) + ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event) + + ApiEvent.TYPING, + ApiEvent.AUDIO_MESSAGE_RECORDING, + ApiEvent.PHOTO_UPLOADING, + ApiEvent.VIDEO_UPLOADING, + ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event) + + ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event) + ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event) + ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event) + } + } + + private fun parseMessageSetFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val cmId = event[1].asLong() + val flags = event[2].asInt() + val peerId = event[3].asLong() + + val eventsToSend = mutableListOf() + + val parsedFlags = MessageFlags.parse(flags) + parsedFlags.forEach { flag -> + when (flag) { + MessageFlags.IMPORTANT -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( + peerId = peerId, + cmId = cmId, + marked = true + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend) + } + + MessageFlags.SPAM -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam( + peerId = peerId, + cmId = cmId + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.MARKED_AS_SPAM, eventToSend) + } + + MessageFlags.DELETED -> { + val eventToSend = + if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) { + LongPollParsedEvent.MessageDeleted( + peerId = peerId, + cmId = cmId, + forAll = true + ) + } else { + LongPollParsedEvent.MessageDeleted( + peerId = peerId, + cmId = cmId, + forAll = false + ) + } + eventsToSend += eventToSend + dispatch(LongPollEvent.MESSAGE_DELETED, eventToSend) + } + + MessageFlags.AUDIO_LISTENED -> { + val eventToSend = LongPollParsedEvent.AudioMessageListened( + peerId = peerId, + cmId = cmId + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.AUDIO_MESSAGE_LISTENED, eventToSend) + } + + else -> Unit + } + } + + dispatchAll(LongPollEvent.MESSAGE_SET_FLAGS, eventsToSend) + } + + private fun parseMessageClearFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val cmId = event[1].asLong() + val flags = event[2].asInt() + val peerId = event[3].asLong() + + val eventsToSend = mutableListOf() + + val parsedFlags = MessageFlags.parse(flags) + + coroutineScope.launch { + parsedFlags.forEach { flag -> + when (flag) { + MessageFlags.IMPORTANT -> { + val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( + peerId = peerId, + cmId = cmId, + marked = false + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend) + } + + MessageFlags.SPAM -> { + if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { + withContext(Dispatchers.IO) { + val message = loadMessage( + peerId = peerId, + cmId = cmId + ) + message?.let { + val eventToSend = + LongPollParsedEvent.MessageMarkedAsNotSpam(message = message) + eventsToSend += eventToSend + dispatch(LongPollEvent.MARKED_AS_NOT_SPAM, eventToSend) + } + } + } + } + + MessageFlags.DELETED -> { + withContext(Dispatchers.IO) { + val message = loadMessage( + peerId = peerId, + cmId = cmId + ) + message?.let { + val eventToSend = + LongPollParsedEvent.MessageRestored(message = message) + eventsToSend += eventToSend + dispatch(LongPollEvent.MESSAGE_RESTORED, eventToSend) + } + } + } + + else -> Unit + } + } + + dispatchAll(LongPollEvent.MESSAGE_CLEAR_FLAGS, eventsToSend) + } + } + + private fun parseMessageNew(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val cmId = event[1].asLong() + val peerId = event[4].asLong() + + coroutineScope.launch(Dispatchers.IO) { + val message = + async { loadMessage(peerId = peerId, cmId = cmId) }.await() + + val convo = + async { + loadConvo( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) + }.await() + + message?.let { + dispatch( + LongPollEvent.MESSAGE_NEW, + LongPollParsedEvent.NewMessage( + message = message, + inArchive = convo?.isArchived == true + ) + ) + } + } + } + + private fun parseMessageEdit(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val cmId = event[1].asLong() + val peerId = event[3].asLong() + + coroutineScope.launch(Dispatchers.IO) { + loadMessage( + peerId = peerId, + cmId = cmId + )?.let { message -> + dispatch(LongPollEvent.MESSAGE_EDITED, LongPollParsedEvent.MessageEdited(message)) + } + } + } + + private fun parseMessageReadIncoming(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + val peerId = event[1].asLong() + val cmId = event[2].asLong() + val unreadCount = event[3].asInt() + + dispatch( + LongPollEvent.INCOMING_MESSAGE_READ, + LongPollParsedEvent.IncomingMessageRead( + peerId = peerId, + cmId = cmId, + unreadCount = unreadCount + ) + ) + } + + private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + val peerId = event[1].asLong() + val cmId = event[2].asLong() + val unreadCount = event[3].asInt() + + dispatch( + LongPollEvent.OUTGOING_MESSAGE_READ, + LongPollParsedEvent.OutgoingMessageRead( + peerId = peerId, + cmId = cmId, + unreadCount = unreadCount + ) + ) + } + + private fun parseChatClearFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val peerId = event[1].asLong() + val flags = event[2].asInt() + + val eventsToSend = mutableListOf() + + val parsedFlags = ConvoFlags.parse(flags) + + coroutineScope.launch(Dispatchers.IO) { + parsedFlags.forEach { flag -> + when (flag) { + ConvoFlags.ARCHIVED -> { + val convo = loadConvo( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) ?: return@forEach + + val message = loadMessage( + peerId = peerId, + cmId = convo.lastCmId + ) + + val eventToSend = LongPollParsedEvent.ChatArchived( + convo = convo.copy(lastMessage = message), + archived = false + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend) + } + + else -> Unit + } + } + + dispatchAll(LongPollEvent.CHAT_CLEAR_FLAGS, eventsToSend) + } + } + + private fun parseChatSetFlags(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val peerId = event[1].asLong() + val flags = event[2].asInt() + + val eventsToSend = mutableListOf() + + val parsedFlags = ConvoFlags.parse(flags) + + coroutineScope.launch(Dispatchers.IO) { + parsedFlags.forEach { flag -> + when (flag) { + ConvoFlags.ARCHIVED -> { + val convo = loadConvo( + peerId = peerId, + extended = true, + fields = VkConstants.ALL_FIELDS + ) ?: return@forEach + + val message = loadMessage( + peerId = peerId, + cmId = convo.lastCmId + ) + + val eventToSend = LongPollParsedEvent.ChatArchived( + convo = convo.copy(lastMessage = message), + archived = true + ) + eventsToSend += eventToSend + dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend) + } + + else -> Unit + } + } + + dispatchAll(LongPollEvent.CHAT_SET_FLAGS, eventsToSend) + } + } + + private fun parseMessagesDeleted(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val peerId = event[1].asLong() + val cmId = event[2].asLong() + + dispatch( + LongPollEvent.CHAT_CLEARED, + LongPollParsedEvent.ChatCleared( + peerId = peerId, + toCmId = cmId + ) + ) + } + + private fun parseChatMajorChanged(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val peerId = event[1].asLong() + val majorId = event[2].asInt() + + dispatch( + LongPollEvent.CHAT_MAJOR_CHANGED, + LongPollParsedEvent.ChatMajorChanged( + peerId = peerId, + majorId = majorId, + ) + ) + } + + private fun parseChatMinorChanged(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val peerId = event[1].asLong() + val minorId = event[2].asInt() + + dispatch( + LongPollEvent.CHAT_MINOR_CHANGED, + LongPollParsedEvent.ChatMinorChanged( + peerId = peerId, + minorId = minorId, + ) + ) + } + + private fun parseInteraction(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType: $event") + + val interactionType = when (eventType) { + ApiEvent.TYPING -> InteractionType.Typing + ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage + ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo + ApiEvent.VIDEO_UPLOADING -> InteractionType.Video + ApiEvent.FILE_UPLOADING -> InteractionType.File + else -> return + } + + val longPollEvent: LongPollEvent = when (eventType) { + ApiEvent.TYPING -> LongPollEvent.TYPING + ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING + ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING + ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING + ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING + else -> return + } + + val peerId = event[1].asLong() + val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId } + val totalCount = event[3].asInt() + val timestamp = event[4].asInt() + + if (userIds.isEmpty()) return + + dispatch( + longPollEvent, + LongPollParsedEvent.Interaction( + interactionType = interactionType, + peerId = peerId, + userIds = userIds, + totalCount = totalCount, + timestamp = timestamp + ) + ) + } + + private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType $event") + + val unreadCount = event[1].asInt() + val unreadUnmutedCount = event[2].asInt() + val showOnlyMuted = event[3].asInt() == 1 + val businessNotifyUnreadCount = event[4].asInt() + val archiveUnreadCount = event[7].asInt() + val archiveUnreadUnmutedCount = event[8].asInt() + val archiveMentionsCount = event[9].asInt() + + dispatch( + LongPollEvent.UNREAD_COUNTER_UPDATE, + LongPollParsedEvent.UnreadCounter( + unread = unreadCount, + unreadUnmuted = unreadUnmutedCount, + showOnlyMuted = showOnlyMuted, + business = businessNotifyUnreadCount, + archive = archiveUnreadCount, + archiveUnmuted = archiveUnreadUnmutedCount, + archiveMentions = archiveMentionsCount + ) + ) + } + + private fun parseMessageUpdated(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType $event") + + val cmId = event[1].asLong() + val peerId = event[4].asLong() + + coroutineScope.launch(Dispatchers.IO) { + loadMessage( + peerId = peerId, + cmId = cmId + )?.let { message -> + dispatch(LongPollEvent.MESSAGE_UPDATED, LongPollParsedEvent.MessageUpdated(message)) + } + } + } + + private fun parseMessageCacheClear(eventType: ApiEvent, event: List) { + Log.d("LongPollEventParser", "$eventType $event") + + val messageId = event[1].asLong() + + coroutineScope.launch(Dispatchers.IO) { + loadMessage(messageId = messageId)?.let { message -> + dispatch( + LongPollEvent.MESSAGE_CACHE_CLEAR, + LongPollParsedEvent.MessageCacheClear(message) + ) + } + } + } + + private suspend fun loadMessage( + peerId: Long? = null, + cmId: Long? = null, + messageId: Long? = null + ): VkMessage? = suspendCoroutine { continuation -> + require((peerId != null && cmId != null) || messageId != null) + + coroutineScope.launch(Dispatchers.IO) { + messagesUseCase.getById( + peerCmIds = null, + peerId = peerId, + messageIds = messageId?.let(::listOf), + cmIds = cmId?.let(::listOf), + extended = true, + fields = VkConstants.ALL_FIELDS + ).listenValue(this) { state -> + state.processState( + error = { error -> + Log.e("LongPollEventParser", "loadMessage: error: $error") + continuation.resume(null) + }, + success = { response -> + val message = response.singleOrNull() ?: run { + continuation.resume(null) + return@listenValue + } + + continuation.resume(message) + } + ) + } + } + } + + private suspend fun loadConvo( + peerId: Long, + extended: Boolean = false, + fields: String? = null + ): VkConvo? = suspendCoroutine { continuation -> + coroutineScope.launch(Dispatchers.IO) { + convoUseCase.getById( + peerIds = listOf(peerId), + extended = extended, + fields = fields + ).listenValue(coroutineScope) { state -> + state.processState( + error = { error -> + Log.e("LongPollEventParser", "loadConvo: error: $error") + continuation.resume(null) + }, + success = { response -> + val convo = response.singleOrNull() ?: run { + continuation.resume(null) + return@listenValue + } + + continuation.resume(convo) + } + ) + } + } + } +} diff --git a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt index 4cf085f9..042cbf75 100644 --- a/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt +++ b/core/domain/src/main/kotlin/dev/meloda/fast/domain/LongPollUpdatesParser.kt @@ -1,35 +1,17 @@ package dev.meloda.fast.domain import android.util.Log -import dev.meloda.fast.common.VkConstants -import dev.meloda.fast.common.extensions.asInt -import dev.meloda.fast.common.extensions.asLong -import dev.meloda.fast.common.extensions.listenValue -import dev.meloda.fast.common.extensions.toList -import dev.meloda.fast.data.UserConfig -import dev.meloda.fast.data.processState -import dev.meloda.fast.model.ApiEvent -import dev.meloda.fast.model.ConvoFlags -import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollParsedEvent -import dev.meloda.fast.model.MessageFlags -import dev.meloda.fast.model.api.domain.VkConvo -import dev.meloda.fast.model.api.domain.VkMessage import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class LongPollUpdatesParser( - private val convoUseCase: ConvoUseCase, - private val messagesUseCase: MessagesUseCase + convoUseCase: ConvoUseCase, + messagesUseCase: MessagesUseCase ) { private val job = SupervisorJob() @@ -44,534 +26,16 @@ class LongPollUpdatesParser( private val coroutineScope = CoroutineScope(coroutineContext) private val eventDispatcher = LongPollEventDispatcher() + private val eventParser = LongPollEventParser( + coroutineScope = coroutineScope, + convoUseCase = convoUseCase, + messagesUseCase = messagesUseCase, + dispatch = eventDispatcher::dispatch, + dispatchAll = eventDispatcher::dispatchAll + ) fun parseNextUpdate(event: List) { - val eventId = event.first().asInt() - - when (val eventType = ApiEvent.parseOrNull(eventId)) { - null -> Log.d("LongPollUpdatesParser", "parseNextUpdate: unknownEvent: $event") - - ApiEvent.MESSAGE_SET_FLAGS -> parseMessageSetFlags(eventType, event) - ApiEvent.MESSAGE_CLEAR_FLAGS -> parseMessageClearFlags(eventType, event) - ApiEvent.MESSAGE_NEW -> parseMessageNew(eventType, event) - ApiEvent.MESSAGE_EDIT -> parseMessageEdit(eventType, event) - ApiEvent.MESSAGE_READ_INCOMING -> parseMessageReadIncoming(eventType, event) - ApiEvent.MESSAGE_READ_OUTGOING -> parseMessageReadOutgoing(eventType, event) - ApiEvent.CHAT_CLEAR_FLAGS -> parseChatClearFlags(eventType, event) - ApiEvent.CHAT_SET_FLAGS -> parseChatSetFlags(eventType, event) - ApiEvent.MESSAGES_DELETED -> parseMessagesDeleted(eventType, event) - ApiEvent.CHAT_MAJOR_CHANGED -> parseChatMajorChanged(eventType, event) - ApiEvent.CHAT_MINOR_CHANGED -> parseChatMinorChanged(eventType, event) - - ApiEvent.TYPING, - ApiEvent.AUDIO_MESSAGE_RECORDING, - ApiEvent.PHOTO_UPLOADING, - ApiEvent.VIDEO_UPLOADING, - ApiEvent.FILE_UPLOADING -> parseInteraction(eventType, event) - - ApiEvent.UNREAD_COUNT_UPDATE -> parseUnreadCounterUpdate(eventType, event) - ApiEvent.MESSAGE_UPDATED -> parseMessageUpdated(eventType, event) - ApiEvent.MESSAGE_CACHE_CLEAR -> parseMessageCacheClear(eventType, event) - } - } - - private fun parseMessageSetFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val cmId = event[1].asLong() - val flags = event[2].asInt() - val peerId = event[3].asLong() - - val eventsToSend = mutableListOf() - - val parsedFlags = MessageFlags.parse(flags) - parsedFlags.forEach { flag -> - when (flag) { - MessageFlags.IMPORTANT -> { - val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( - peerId = peerId, - cmId = cmId, - marked = true - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend) - } - - MessageFlags.SPAM -> { - val eventToSend = LongPollParsedEvent.MessageMarkedAsSpam( - peerId = peerId, - cmId = cmId - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MARKED_AS_SPAM, eventToSend) - } - - MessageFlags.DELETED -> { - val eventToSend = - if (parsedFlags.contains(MessageFlags.DELETED_FOR_ALL)) { - LongPollParsedEvent.MessageDeleted( - peerId = peerId, - cmId = cmId, - forAll = true - ) - } else { - LongPollParsedEvent.MessageDeleted( - peerId = peerId, - cmId = cmId, - forAll = false - ) - } - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MESSAGE_DELETED, eventToSend) - } - - MessageFlags.AUDIO_LISTENED -> { - val eventToSend = LongPollParsedEvent.AudioMessageListened( - peerId = peerId, - cmId = cmId - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.AUDIO_MESSAGE_LISTENED, eventToSend) - } - - else -> Unit - } - } - - eventDispatcher.dispatchAll(LongPollEvent.MESSAGE_SET_FLAGS, eventsToSend) - } - - private fun parseMessageClearFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val cmId = event[1].asLong() - val flags = event[2].asInt() - val peerId = event[3].asLong() - - val eventsToSend = mutableListOf() - - val parsedFlags = MessageFlags.parse(flags) - - coroutineScope.launch { - parsedFlags.forEach { flag -> - when (flag) { - MessageFlags.IMPORTANT -> { - val eventToSend = LongPollParsedEvent.MessageMarkedAsImportant( - peerId = peerId, - cmId = cmId, - marked = false - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MARKED_AS_IMPORTANT, eventToSend) - } - - MessageFlags.SPAM -> { - if (parsedFlags.contains(MessageFlags.CANCEL_SPAM)) { - withContext(Dispatchers.IO) { - val message = loadMessage( - peerId = peerId, - cmId = cmId - ) - message?.let { - val eventToSend = - LongPollParsedEvent.MessageMarkedAsNotSpam(message = message) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MARKED_AS_NOT_SPAM, eventToSend) - } - } - } - } - - MessageFlags.DELETED -> { - withContext(Dispatchers.IO) { - val message = loadMessage( - peerId = peerId, - cmId = cmId - ) - message?.let { - val eventToSend = - LongPollParsedEvent.MessageRestored(message = message) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.MESSAGE_RESTORED, eventToSend) - } - } - } - - else -> Unit - } - } - - eventDispatcher.dispatchAll(LongPollEvent.MESSAGE_CLEAR_FLAGS, eventsToSend) - } - } - - private fun parseMessageNew(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val cmId = event[1].asLong() - val peerId = event[4].asLong() - - coroutineScope.launch(Dispatchers.IO) { - val message = - async { loadMessage(peerId = peerId, cmId = cmId) }.await() - - val convo = - async { - loadConvo( - peerId = peerId, - extended = true, - fields = VkConstants.ALL_FIELDS - ) - }.await() - - message?.let { - eventDispatcher.dispatch( - LongPollEvent.MESSAGE_NEW, - LongPollParsedEvent.NewMessage( - message = message, - inArchive = convo?.isArchived == true - // TODO: 03-Apr-25, Danil Nikolaev: - // load user settings about restoring chats with - // enabled notifications from archive - ) - ) - } - } - } - - private fun parseMessageEdit(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val cmId = event[1].asLong() - val peerId = event[3].asLong() - - coroutineScope.launch(Dispatchers.IO) { - loadMessage( - peerId = peerId, - cmId = cmId - )?.let { message -> - eventDispatcher.dispatch(LongPollEvent.MESSAGE_EDITED, LongPollParsedEvent.MessageEdited(message)) - } - } - } - - private fun parseMessageReadIncoming(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val peerId = event[1].asLong() - val cmId = event[2].asLong() - val unreadCount = event[3].asInt() - - eventDispatcher.dispatch( - LongPollEvent.INCOMING_MESSAGE_READ, - LongPollParsedEvent.IncomingMessageRead( - peerId = peerId, - cmId = cmId, - unreadCount = unreadCount - ) - ) - } - - private fun parseMessageReadOutgoing(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - val peerId = event[1].asLong() - val cmId = event[2].asLong() - val unreadCount = event[3].asInt() - - eventDispatcher.dispatch( - LongPollEvent.OUTGOING_MESSAGE_READ, - LongPollParsedEvent.OutgoingMessageRead( - peerId = peerId, - cmId = cmId, - unreadCount = unreadCount - ) - ) - } - - private fun parseChatClearFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asLong() - val flags = event[2].asInt() - - val eventsToSend = mutableListOf() - - val parsedFlags = ConvoFlags.parse(flags) - - coroutineScope.launch(Dispatchers.IO) { - parsedFlags.forEach { flag -> - when (flag) { - ConvoFlags.ARCHIVED -> { - val convo = loadConvo( - peerId = peerId, - extended = true, - fields = VkConstants.ALL_FIELDS - ) ?: return@forEach - - val message = loadMessage( - peerId = peerId, - cmId = convo.lastCmId - ) - - val eventToSend = LongPollParsedEvent.ChatArchived( - convo = convo.copy(lastMessage = message), - archived = false - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend) - } - - else -> Unit - } - } - - eventDispatcher.dispatchAll(LongPollEvent.CHAT_CLEAR_FLAGS, eventsToSend) - } - } - - private fun parseChatSetFlags(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asLong() - val flags = event[2].asInt() - - val eventsToSend = mutableListOf() - - val parsedFlags = ConvoFlags.parse(flags) - - coroutineScope.launch(Dispatchers.IO) { - parsedFlags.forEach { flag -> - when (flag) { - ConvoFlags.ARCHIVED -> { - val convo = loadConvo( - peerId = peerId, - extended = true, - fields = VkConstants.ALL_FIELDS - ) ?: return@forEach - - val message = loadMessage( - peerId = peerId, - cmId = convo.lastCmId - ) - - val eventToSend = LongPollParsedEvent.ChatArchived( - convo = convo.copy(lastMessage = message), - archived = true - ) - eventsToSend += eventToSend - eventDispatcher.dispatch(LongPollEvent.CHAT_ARCHIVED, eventToSend) - } - - else -> Unit - } - } - - eventDispatcher.dispatchAll(LongPollEvent.CHAT_SET_FLAGS, eventsToSend) - } - } - - private fun parseMessagesDeleted(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asLong() - val cmId = event[2].asLong() - - eventDispatcher.dispatch( - LongPollEvent.CHAT_CLEARED, - LongPollParsedEvent.ChatCleared( - peerId = peerId, - toCmId = cmId - ) - ) - } - - private fun parseChatMajorChanged(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asLong() - val majorId = event[2].asInt() - - eventDispatcher.dispatch( - LongPollEvent.CHAT_MAJOR_CHANGED, - LongPollParsedEvent.ChatMajorChanged( - peerId = peerId, - majorId = majorId, - ) - ) - } - - private fun parseChatMinorChanged(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val peerId = event[1].asLong() - val minorId = event[2].asInt() - - eventDispatcher.dispatch( - LongPollEvent.CHAT_MINOR_CHANGED, - LongPollParsedEvent.ChatMinorChanged( - peerId = peerId, - minorId = minorId, - ) - ) - } - - private fun parseInteraction(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType: $event") - - val interactionType = when (eventType) { - ApiEvent.TYPING -> InteractionType.Typing - ApiEvent.AUDIO_MESSAGE_RECORDING -> InteractionType.VoiceMessage - ApiEvent.PHOTO_UPLOADING -> InteractionType.Photo - ApiEvent.VIDEO_UPLOADING -> InteractionType.Video - ApiEvent.FILE_UPLOADING -> InteractionType.File - else -> return - } - - val longPollEvent: LongPollEvent = when (eventType) { - ApiEvent.TYPING -> LongPollEvent.TYPING - ApiEvent.AUDIO_MESSAGE_RECORDING -> LongPollEvent.AUDIO_MESSAGE_RECORDING - ApiEvent.PHOTO_UPLOADING -> LongPollEvent.PHOTO_UPLOADING - ApiEvent.VIDEO_UPLOADING -> LongPollEvent.VIDEO_UPLOADING - ApiEvent.FILE_UPLOADING -> LongPollEvent.FILE_UPLOADING - else -> return - } - - val peerId = event[1].asLong() - val userIds = event[2].toList(Any::asLong).filter { it != UserConfig.userId } - val totalCount = event[3].asInt() - val timestamp = event[4].asInt() - - // if userIds contains only account's id, then we don't need to show our status - if (userIds.isEmpty()) return - - eventDispatcher.dispatch( - longPollEvent, - LongPollParsedEvent.Interaction( - interactionType = interactionType, - peerId = peerId, - userIds = userIds, - totalCount = totalCount, - timestamp = timestamp - ) - ) - } - - private fun parseUnreadCounterUpdate(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType $event") - - val unreadCount = event[1].asInt() - val unreadUnmutedCount = event[2].asInt() - val showOnlyMuted = event[3].asInt() == 1 - val businessNotifyUnreadCount = event[4].asInt() - val archiveUnreadCount = event[7].asInt() - val archiveUnreadUnmutedCount = event[8].asInt() - val archiveMentionsCount = event[9].asInt() - - eventDispatcher.dispatch( - LongPollEvent.UNREAD_COUNTER_UPDATE, - LongPollParsedEvent.UnreadCounter( - unread = unreadCount, - unreadUnmuted = unreadUnmutedCount, - showOnlyMuted = showOnlyMuted, - business = businessNotifyUnreadCount, - archive = archiveUnreadCount, - archiveUnmuted = archiveUnreadUnmutedCount, - archiveMentions = archiveMentionsCount - ) - ) - } - - private fun parseMessageUpdated(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType $event") - - val cmId = event[1].asLong() - val peerId = event[4].asLong() - - coroutineScope.launch(Dispatchers.IO) { - loadMessage( - peerId = peerId, - cmId = cmId - )?.let { message -> - eventDispatcher.dispatch(LongPollEvent.MESSAGE_UPDATED, LongPollParsedEvent.MessageUpdated(message)) - } - } - } - - private fun parseMessageCacheClear(eventType: ApiEvent, event: List) { - Log.d("LongPollUpdatesParser", "$eventType $event") - - val messageId = event[1].asLong() - - coroutineScope.launch(Dispatchers.IO) { - loadMessage(messageId = messageId)?.let { message -> - eventDispatcher.dispatch( - LongPollEvent.MESSAGE_CACHE_CLEAR, - LongPollParsedEvent.MessageCacheClear(message) - ) - } - } - } - - private suspend fun loadMessage( - peerId: Long? = null, - cmId: Long? = null, - messageId: Long? = null - ): VkMessage? = suspendCoroutine { continuation -> - require((peerId != null && cmId != null) || messageId != null) - - coroutineScope.launch(Dispatchers.IO) { - messagesUseCase.getById( - peerCmIds = null, - peerId = peerId, - messageIds = messageId?.let(::listOf), - cmIds = cmId?.let(::listOf), - extended = true, - fields = VkConstants.ALL_FIELDS - ).listenValue(this) { state -> - state.processState( - error = { error -> - Log.e("LongPollUpdatesParser", "loadMessage: error: $error") - continuation.resume(null) - }, - success = { response -> - val message = response.singleOrNull() ?: run { - continuation.resume(null) - return@listenValue - } - - continuation.resume(message) - } - ) - } - } - } - - private suspend fun loadConvo( - peerId: Long, - extended: Boolean = false, - fields: String? = null - ): VkConvo? = suspendCoroutine { continuation -> - coroutineScope.launch(Dispatchers.IO) { - convoUseCase.getById( - peerIds = listOf(peerId), - extended = extended, - fields = fields - ).listenValue(coroutineScope) { state -> - state.processState( - error = { error -> - Log.e("LongPollUpdatesParser", "loadConvo: error: $error") - continuation.resume(null) - }, - success = { response -> - val convo = response.singleOrNull() ?: run { - continuation.resume(null) - return@listenValue - } - - continuation.resume(convo) - } - ) - } - } + eventParser.parseNextUpdate(event) } fun onMessageSetFlags(block: (LongPollParsedEvent) -> Unit) { diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryMessageActions.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryMessageActions.kt new file mode 100644 index 00000000..9928e56f --- /dev/null +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryMessageActions.kt @@ -0,0 +1,520 @@ +package dev.meloda.fast.messageshistory + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.core.content.FileProvider +import androidx.core.graphics.drawable.toBitmapOrNull +import coil.imageLoader +import coil.request.ImageRequest +import com.conena.nanokt.collections.indexOfFirstOrNull +import dev.meloda.fast.common.extensions.listenValue +import dev.meloda.fast.common.extensions.orDots +import dev.meloda.fast.common.extensions.removeIfCompat +import dev.meloda.fast.common.extensions.setValue +import dev.meloda.fast.common.provider.ResourceProvider +import dev.meloda.fast.data.State +import dev.meloda.fast.data.UserConfig +import dev.meloda.fast.data.VkMemoryCache +import dev.meloda.fast.data.VkUtils +import dev.meloda.fast.data.processState +import dev.meloda.fast.domain.MessagesUseCase +import dev.meloda.fast.domain.util.extractReplySummary +import dev.meloda.fast.domain.util.extractReplyTitle +import dev.meloda.fast.domain.util.extractTitle +import dev.meloda.fast.messageshistory.model.ActionMode +import dev.meloda.fast.messageshistory.model.MessageDialog +import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState +import dev.meloda.fast.model.BaseError +import dev.meloda.fast.model.api.domain.FormatDataType +import dev.meloda.fast.model.api.domain.VkMessage +import dev.meloda.fast.model.api.domain.VkPhotoDomain +import dev.meloda.fast.ui.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File +import java.io.FileOutputStream +import kotlin.random.Random + +internal class MessagesHistoryMessageActions( + private val applicationContext: Context, + private val viewModelScope: CoroutineScope, + private val messagesUseCase: MessagesUseCase, + private val resourceProvider: ResourceProvider, + private val screenState: MutableStateFlow, + private val messages: MutableStateFlow>, + private val baseError: MutableStateFlow, + private val showKeyboard: MutableStateFlow, + private val dialog: MutableStateFlow, + private val syncUiMessages: () -> Unit, + private val onPinnedMessageChanged: (VkMessage?) -> Unit +) { + private var lastMessageText: String? = null + private val sendingMessages: MutableList = mutableListOf() + private val failedMessages: MutableList = mutableListOf() + private var replyToCmId: Long? = null + private var editMessage: VkMessage? = null + private var formatData = VkMessage.FormatData("1", emptyList()) + + fun replyToMessage(cmId: Long) { + val messageToReply = messages.value.find { it.cmId == cmId } ?: return + + showKeyboard.setValue { true } + replyToCmId = cmId + screenState.setValue { old -> + old.copy( + replyTitle = messageToReply.extractTitle(), + replyText = messageToReply.extractReplySummary(resourceProvider.resources) + ) + } + } + + fun editMessage(cmId: Long) { + screenState.setValue { old -> old.copy(editCmId = cmId) } + + val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return + editMessage = messageToEdit + + lastMessageText = screenState.value.message.text + + var newState = screenState.value.copy( + message = TextFieldValue( + text = messageToEdit.text.orEmpty(), + selection = TextRange(messageToEdit.text.orEmpty().length) + ), + actionMode = ActionMode.EDIT + ) + + messageToEdit.replyMessage?.let { reply -> + replyToCmId = reply.cmId + newState = newState.copy( + replyTitle = reply.extractReplyTitle(), + replyText = reply.extractReplySummary(resourceProvider.resources) + ) + } + + showKeyboard.setValue { true } + screenState.setValue { newState } + } + + fun stopEditMessage() { + val lastText = lastMessageText.orEmpty().trim() + + screenState.setValue { old -> + old.copy( + editCmId = null, + message = TextFieldValue( + text = lastText, + selection = TextRange(lastText.length) + ), + actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO else ActionMode.SEND, + replyTitle = null, + replyText = null + ) + } + } + + fun onBoldClicked() = updateFormatting(FormatDataType.BOLD) + fun onItalicClicked() = updateFormatting(FormatDataType.ITALIC) + fun onUnderlineClicked() = updateFormatting(FormatDataType.UNDERLINE) + + fun onRegularClicked() { + formatData = formatData.copy(items = emptyList()) + updateStyles() + } + + fun onReplyCloseClicked() { + replyToCmId = null + screenState.setValue { old -> + old.copy( + replyTitle = null, + replyText = null + ) + } + } + + fun sendMessage() { + lastMessageText = screenState.value.message.text + + val newMessage = VkMessage( + id = -1L - sendingMessages.size, + cmId = -1L - sendingMessages.size, + text = lastMessageText, + isOut = true, + peerId = screenState.value.convoId, + fromId = UserConfig.userId, + date = (System.currentTimeMillis() / 1000).toInt(), + randomId = Random.nextInt().toLong(), + action = null, + actionMemberId = null, + actionText = null, + actionCmId = null, + actionMessage = null, + updateTime = null, + isImportant = false, + forwards = null, + attachments = null, + replyMessage = when { + replyToCmId != null -> messages.value.find { it.cmId == replyToCmId } + else -> null + }, + geoType = null, + user = VkMemoryCache.getUser(UserConfig.userId), + group = null, + actionUser = null, + actionGroup = null, + isPinned = false, + isSpam = false, + pinnedAt = null, + formatData = formatData, + ) + formatData = formatData.copy(items = emptyList()) + sendingMessages += newMessage + messages.setValue { old -> listOf(newMessage).plus(old) } + syncUiMessages() + + screenState.setValue { old -> + old.copy( + message = TextFieldValue(), + actionMode = ActionMode.RECORD_AUDIO, + replyTitle = null, + replyText = null + ) + } + + val replyCmId = replyToCmId + replyToCmId = null + + val forward = when { + replyCmId != null -> { + buildJsonObject { + put("peer_id", screenState.value.convoId) + put("conversation_message_ids", buildJsonArray { add(replyCmId) }) + put("is_reply", true) + }.toString() + } + + else -> null + } + + messagesUseCase.sendMessage( + peerId = screenState.value.convoId, + randomId = newMessage.randomId, + message = newMessage.text, + forward = forward, + attachments = null, + formatData = newMessage.formatData, + ).listenValue(viewModelScope) { state -> + state.processState( + any = { sendingMessages.remove(newMessage) }, + error = { error -> + Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error") + + val failedId = -500_000L - failedMessages.size + val newFailedMessage = newMessage.copy(id = failedId) + failedMessages += newFailedMessage + + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newFailedMessage + messages.setValue { newMessages } + syncUiMessages() + }, + success = { response -> + val newMessages = messages.value.toMutableList() + newMessages[newMessages.indexOf(newMessage)] = newMessage.copy( + id = response.messageId, + cmId = response.cmId + ) + messages.setValue { newMessages } + syncUiMessages() + } + ) + } + } + + fun confirmDeleteCurrentEditMessage() { + val currentMessage = editMessage ?: return + dialog.setValue { MessageDialog.MessageDelete(currentMessage) } + } + + fun editCurrentEditMessage() { + replyToCmId = null + + val newText = screenState.value.message.text + val lastText = lastMessageText.orEmpty().trim() + + screenState.setValue { old -> + old.copy( + editCmId = null, + message = TextFieldValue( + text = lastText, + selection = TextRange(lastText.length) + ), + actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO else ActionMode.SEND, + replyTitle = null, + replyText = null + ) + } + + syncUiMessages() + + val newMessage = editMessage?.copy( + replyMessage = if (replyToCmId == null) null else editMessage?.replyMessage, + text = newText + ) ?: return + + Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage") + } + + fun markAsImportant(messageIds: List, important: Boolean) { + messagesUseCase.markAsImportant( + peerId = screenState.value.convoId, + messageIds = messageIds, + important = important + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value + .toMutableList() + .map { message -> + if (message.id in messageIds) { + message.copy(isImportant = important) + } else { + message + } + } + messages.setValue { newMessages } + syncUiMessages() + } + ) + } + } + + fun deleteMessage( + messageIds: List, + spam: Boolean = false, + deleteForAll: Boolean = false, + onSuccess: () -> Unit = {} + ) { + messagesUseCase.delete( + peerId = screenState.value.convoId, + messageIds = messageIds, + spam = spam, + deleteForAll = deleteForAll + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + onSuccess() + val newMessages = messages.value.toMutableList() + val messagesToDelete = newMessages.filter { it.id in messageIds } + newMessages.removeAll(messagesToDelete) + messages.setValue { newMessages } + syncUiMessages() + } + ) + } + } + + fun pinMessage(messageId: Long) { + messagesUseCase.pin( + peerId = screenState.value.convoId, + messageId = messageId, + cmId = null + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { pinnedMessage -> + onPinnedMessageChanged(pinnedMessage) + + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == messageId } + if (index != null) { + newMessages[index] = pinnedMessage + messages.setValue { newMessages } + syncUiMessages() + } + } + ) + } + } + + fun unpinMessage(messageId: Long) { + messagesUseCase.unpin(screenState.value.convoId) + .listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val newMessages = messages.value.toMutableList() + val index = newMessages.indexOfFirstOrNull { it.id == messageId } + if (index != null) { + newMessages[index] = newMessages[index].copy(isPinned = false) + messages.setValue { newMessages } + syncUiMessages() + } + + onPinnedMessageChanged(null) + } + ) + } + } + + fun readMessage(message: VkMessage) { + messagesUseCase.markAsRead( + peerId = screenState.value.convoId, + startMessageId = message.id + ).listenValue(viewModelScope) { state -> + state.processState( + error = ::handleError, + success = { + val oldConvo = screenState.value.convo + val newConvo = oldConvo.copy( + inRead = if (!message.isOut) message.id else oldConvo.inRead, + outRead = if (message.isOut) message.id else oldConvo.outRead + ) + + screenState.setValue { old -> + old.copy(convo = newConvo) + } + + syncUiMessages() + } + ) + } + } + + fun copyMessage(message: VkMessage) { + val clipboardManager = + applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + val messageToCopy = message.text.orEmpty().trim() + if (messageToCopy.isEmpty()) { + val photo = with(message.attachments.orEmpty()) { + if (size == 1 && all { it is VkPhotoDomain }) { + first() as? VkPhotoDomain + } else null + } ?: return + + val photoMaxSize = photo.getMaxSize() ?: return + + viewModelScope.launch(Dispatchers.IO) { + val drawable = applicationContext.imageLoader.execute( + ImageRequest.Builder(applicationContext) + .data(photoMaxSize.url) + .build() + ).drawable ?: return@launch + + val imagesDir = File(applicationContext.cacheDir, "images") + if (!imagesDir.exists()) imagesDir.mkdirs() + val imageFile = File(imagesDir, "shared_image_id${photo.id}.png") + FileOutputStream(imageFile).use { + drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it) + } + + val uri = FileProvider.getUriForFile( + applicationContext, + "${applicationContext.packageName}.provider", + imageFile + ) + + val clip = ClipData.newUri(applicationContext.contentResolver, "Image", uri) + clipboardManager.setPrimaryClip(clip) + + withContext(Dispatchers.Main) { + Toast.makeText( + applicationContext, + "Image copied to clipboard", + Toast.LENGTH_SHORT + ).show() + } + } + return + } + + clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", messageToCopy)) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { + Toast.makeText(applicationContext, R.string.copied_to_clipboard, Toast.LENGTH_SHORT) + .show() + } + } + + private fun updateFormatting(type: FormatDataType) { + val selectionRange = screenState.value.message.selection + val newItems = formatData.items.toMutableList() + val wasRemoved = newItems.removeIfCompat { + it.type == type && + it.offset == selectionRange.start && + it.offset + it.length == selectionRange.end + } + + if (!wasRemoved) { + newItems += VkMessage.FormatData.Item( + offset = selectionRange.start, + length = selectionRange.end - selectionRange.start, + type = type, + url = null + ) + } + + formatData = formatData.copy(items = newItems) + updateStyles() + } + + private fun updateStyles() { + val annotations = + mutableListOf>() + + formatData.items.forEach { item -> + val spanStyle = when (item.type) { + FormatDataType.BOLD -> SpanStyle(fontWeight = FontWeight.SemiBold) + FormatDataType.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + FormatDataType.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline) + FormatDataType.URL -> null + } + + spanStyle?.let { + annotations += AnnotatedString.Range( + item = spanStyle, + start = item.offset, + end = item.offset + item.length + ) + } + } + + val newText = AnnotatedString( + text = screenState.value.message.text, + annotations = annotations + ) + + screenState.setValue { old -> + old.copy(message = old.message.copy(annotatedString = newText)) + } + } + + private fun handleError(error: State.Error) { + VkUtils.parseError(error)?.let { newBaseError -> + baseError.setValue { newBaseError } + } + } +} diff --git a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt index 7382324a..48fe91e7 100644 --- a/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt +++ b/feature/messageshistory/src/main/kotlin/dev/meloda/fast/messageshistory/MessagesHistoryViewModelImpl.kt @@ -1,34 +1,18 @@ package dev.meloda.fast.messageshistory -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context -import android.graphics.Bitmap -import android.os.Build import android.os.Bundle import android.util.Log -import android.widget.Toast -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextDecoration -import androidx.core.content.FileProvider -import androidx.core.graphics.drawable.toBitmapOrNull import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import coil.imageLoader -import coil.request.ImageRequest import com.conena.nanokt.collections.indexOfFirstOrNull import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.extensions.getParcelableCompat import dev.meloda.fast.common.extensions.listenValue import dev.meloda.fast.common.extensions.orDots -import dev.meloda.fast.common.extensions.removeIfCompat import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.paging.canPaginate as canPaginatePage @@ -36,20 +20,16 @@ import dev.meloda.fast.common.paging.isPaginationExhausted as isPaginationExhaus import dev.meloda.fast.common.paging.loadingFlags import dev.meloda.fast.common.paging.mergePage import dev.meloda.fast.data.State -import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.VkUtils import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.processState import dev.meloda.fast.datastore.AppSettings -import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.domain.ConvoUseCase import dev.meloda.fast.domain.GetMessageReadPeersUseCase import dev.meloda.fast.domain.LoadConvosByIdUseCase import dev.meloda.fast.domain.LongPollUpdatesParser import dev.meloda.fast.domain.MessagesUseCase import dev.meloda.fast.domain.util.extractAvatar -import dev.meloda.fast.domain.util.extractReplySummary -import dev.meloda.fast.domain.util.extractReplyTitle import dev.meloda.fast.domain.util.extractTitle import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.MessageDialog @@ -58,33 +38,20 @@ import dev.meloda.fast.messageshistory.model.MessageOption import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState import dev.meloda.fast.messageshistory.navigation.MessagesHistory import dev.meloda.fast.model.BaseError -import dev.meloda.fast.model.api.domain.FormatDataType import dev.meloda.fast.model.api.domain.VkMessage -import dev.meloda.fast.model.api.domain.VkPhotoDomain -import dev.meloda.fast.ui.R import dev.meloda.fast.ui.model.vk.MessageUiItem -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import java.io.File -import java.io.FileOutputStream import kotlin.coroutines.resume import kotlin.math.abs -import kotlin.random.Random class MessagesHistoryViewModelImpl( private val applicationContext: Context, private val messagesUseCase: MessagesUseCase, private val convoUseCase: ConvoUseCase, private val resourceProvider: ResourceProvider, - private val userSettings: UserSettings, private val loadConvosByIdUseCase: LoadConvosByIdUseCase, private val getMessageReadPeersUseCase: GetMessageReadPeersUseCase, updatesParser: LongPollUpdatesParser, @@ -110,14 +77,19 @@ class MessagesHistoryViewModelImpl( override val messages = MutableStateFlow>(emptyList()) override val uiMessages = MutableStateFlow>(emptyList()) - private var lastMessageText: String? = null - - private val sendingMessages: MutableList = mutableListOf() - private val failedMessages: MutableList = mutableListOf() - - private var replyToCmId: Long? = null - - private var editMessage: VkMessage? = null + private val messageActions = MessagesHistoryMessageActions( + applicationContext = applicationContext, + viewModelScope = viewModelScope, + messagesUseCase = messagesUseCase, + resourceProvider = resourceProvider, + screenState = screenState, + messages = messages, + baseError = baseError, + showKeyboard = showKeyboard, + dialog = dialog, + syncUiMessages = ::syncUiMessages, + onPinnedMessageChanged = ::handlePinnedMessage + ) private val longPollEventHandler = MessagesHistoryLongPollEventHandler( screenState = screenState, @@ -177,7 +149,7 @@ class MessagesHistoryViewModelImpl( return } - deleteMessage( + messageActions.deleteMessage( messageIds = listOf(dialog.message.id), deleteForAll = deleteForEveryone ) @@ -192,7 +164,7 @@ class MessagesHistoryViewModelImpl( .filter { it.id > 0 } .map(VkMessage::id) - deleteMessage( + messageActions.deleteMessage( messageIds = messageIdsToDelete, deleteForAll = deleteForEveryone, onSuccess = { @@ -206,15 +178,15 @@ class MessagesHistoryViewModelImpl( } is MessageDialog.MessagePin -> { - pinMessage(dialog.messageId) + messageActions.pinMessage(dialog.messageId) } is MessageDialog.MessageUnpin -> { - unpinMessage(dialog.messageId) + messageActions.unpinMessage(dialog.messageId) } is MessageDialog.MessageMarkImportance -> { - markAsImportant( + messageActions.markAsImportant( messageIds = listOf(dialog.message.id), important = dialog.isImportant ) @@ -222,7 +194,7 @@ class MessagesHistoryViewModelImpl( is MessageDialog.MessageSpam -> { if (dialog.isSpam) { - deleteMessage( + messageActions.deleteMessage( messageIds = listOf(dialog.message.id), spam = true ) @@ -250,7 +222,7 @@ class MessagesHistoryViewModelImpl( // TODO: 28-Mar-25, Danil Nikolaev: retry sending } - MessageOption.Reply -> replyToMessage(cmId) + MessageOption.Reply -> messageActions.replyToMessage(cmId) MessageOption.ForwardHere -> { @@ -273,11 +245,11 @@ class MessagesHistoryViewModelImpl( } MessageOption.Read -> { - readMessage(dialog.message) + messageActions.readMessage(dialog.message) } MessageOption.Copy -> { - copyMessage(dialog.message) + messageActions.copyMessage(dialog.message) } MessageOption.MarkAsImportant, @@ -301,7 +273,7 @@ class MessagesHistoryViewModelImpl( } MessageOption.Edit -> { - editMessage(cmId) + messageActions.editMessage(cmId) syncUiMessages() } @@ -332,7 +304,7 @@ class MessagesHistoryViewModelImpl( } if (screenState.value.editCmId != null) { - stopEditMessage() + messageActions.stopEditMessage() } syncUiMessages() @@ -380,9 +352,9 @@ class MessagesHistoryViewModelImpl( override fun onActionButtonClicked() { when (screenState.value.actionMode) { - ActionMode.DELETE -> confirmDeleteCurrentEditMessage() + ActionMode.DELETE -> messageActions.confirmDeleteCurrentEditMessage() - ActionMode.EDIT -> editCurrentEditMessage() + ActionMode.EDIT -> messageActions.editCurrentEditMessage() ActionMode.RECORD_AUDIO -> { screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) } @@ -392,7 +364,7 @@ class MessagesHistoryViewModelImpl( screenState.setValue { it.copy(actionMode = ActionMode.RECORD_AUDIO) } } - ActionMode.SEND -> sendMessage() + ActionMode.SEND -> messageActions.sendMessage() } } @@ -463,7 +435,7 @@ class MessagesHistoryViewModelImpl( selectedMessages.setValue { emptyList() } - editMessage(cmId) + messageActions.editMessage(cmId) syncUiMessages() } @@ -474,175 +446,16 @@ class MessagesHistoryViewModelImpl( } } - private fun replyToMessage(cmId: Long) { - val messageToReply = messages.value.find { it.cmId == cmId } ?: return - - showKeyboard.setValue { true } - replyToCmId = cmId - screenState.setValue { old -> - old.copy( - replyTitle = messageToReply.extractTitle(), - replyText = messageToReply.extractReplySummary(resourceProvider.resources) - ) - } - } - - private fun editMessage(cmId: Long) { - this.screenState.setValue { old -> - old.copy(editCmId = cmId) - } - - val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return - editMessage = messageToEdit - - lastMessageText = screenState.value.message.text - - var newState = screenState.value.copy( - message = TextFieldValue( - text = messageToEdit.text.orEmpty(), - selection = TextRange(messageToEdit.text.orEmpty().length) - ), - actionMode = ActionMode.EDIT - ) - - messageToEdit.replyMessage?.let { reply -> - replyToCmId = reply.cmId - newState = newState.copy( - replyTitle = reply.extractReplyTitle(), - replyText = reply.extractReplySummary(resourceProvider.resources) - ) - } - - showKeyboard.setValue { true } - screenState.setValue { newState } - } - - private fun stopEditMessage() { - val lastText = lastMessageText.orEmpty().trim() - - screenState.setValue { old -> - old.copy( - editCmId = null, - message = TextFieldValue( - text = lastText, - selection = TextRange(lastText.length) - ), - actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO - else ActionMode.SEND, - - // TODO: 13/03/2026, Danil Nikolaev: use last reply - replyTitle = null, - replyText = null - ) - } - } - - private var formatData = VkMessage.FormatData("1", emptyList()) - - private fun updateStyles() { - val annotations = - mutableListOf>() - - formatData.items.forEachIndexed { index, item -> - val spanStyle = when (item.type) { - FormatDataType.BOLD -> { - SpanStyle(fontWeight = FontWeight.SemiBold) - } - - FormatDataType.ITALIC -> { - SpanStyle(fontStyle = FontStyle.Italic) - } - - FormatDataType.UNDERLINE -> { - SpanStyle(textDecoration = TextDecoration.Underline) - } - - FormatDataType.URL -> null - } - - spanStyle?.let { - annotations += AnnotatedString.Range( - item = spanStyle, - start = item.offset, - end = item.offset + item.length - ) - } - } - - val newText = AnnotatedString( - text = screenState.value.message.text, - annotations = annotations - ) - - screenState.setValue { old -> - old.copy(message = old.message.copy(annotatedString = newText)) - } - } - override fun onBoldClicked() { - val selectionRange = screenState.value.message.selection - val newItems = formatData.items.toMutableList() - val wasRemoved = newItems.removeIfCompat { - it.type == FormatDataType.BOLD && - it.offset == selectionRange.start && - it.offset + it.length == selectionRange.end - } - - if (!wasRemoved) { - newItems += VkMessage.FormatData.Item( - offset = selectionRange.start, - length = selectionRange.end - selectionRange.start, - type = FormatDataType.BOLD, - url = null - ) - } - - formatData = formatData.copy(items = newItems) - updateStyles() + messageActions.onBoldClicked() } override fun onItalicClicked() { - val selectionRange = screenState.value.message.selection - val newItems = formatData.items.toMutableList() - val wasRemoved = newItems.removeIfCompat { - it.type == FormatDataType.ITALIC && - it.offset == selectionRange.start && - it.offset + it.length == selectionRange.end - } - - if (!wasRemoved) { - newItems += VkMessage.FormatData.Item( - offset = selectionRange.start, - length = selectionRange.end - selectionRange.start, - type = FormatDataType.ITALIC, - url = null - ) - } - - formatData = formatData.copy(items = newItems) - updateStyles() + messageActions.onItalicClicked() } override fun onUnderlineClicked() { - val selectionRange = screenState.value.message.selection - val newItems = formatData.items.toMutableList() - val wasRemoved = newItems.removeIfCompat { - it.type == FormatDataType.UNDERLINE && - it.offset == selectionRange.start && - it.offset + it.length == selectionRange.end - } - - if (!wasRemoved) { - newItems += VkMessage.FormatData.Item( - offset = selectionRange.start, - length = selectionRange.end - selectionRange.start, - type = FormatDataType.UNDERLINE, - url = null - ) - } - - formatData = formatData.copy(items = newItems) - updateStyles() + messageActions.onUnderlineClicked() } override fun onLinkClicked() { @@ -650,23 +463,15 @@ class MessagesHistoryViewModelImpl( } override fun onRegularClicked() { - formatData = formatData.copy(items = emptyList()) - updateStyles() + messageActions.onRegularClicked() } override fun onReplyCloseClicked() { - replyToCmId = null - - screenState.setValue { old -> - old.copy( - replyTitle = null, - replyText = null - ) - } + messageActions.onReplyCloseClicked() } override fun onRequestReplyToMessage(cmId: Long) { - replyToMessage(cmId) + messageActions.replyToMessage(cmId) } override fun onKeyboardShown() { @@ -827,337 +632,6 @@ class MessagesHistoryViewModelImpl( } } - private fun sendMessage() { - lastMessageText = screenState.value.message.text - - val newMessage = VkMessage( - id = -1L - sendingMessages.size, - cmId = -1L - sendingMessages.size, - text = lastMessageText, - isOut = true, - peerId = screenState.value.convoId, - fromId = UserConfig.userId, - date = (System.currentTimeMillis() / 1000).toInt(), - randomId = Random.nextInt().toLong(), - action = null, - actionMemberId = null, - actionText = null, - actionCmId = null, - actionMessage = null, - updateTime = null, - isImportant = false, - forwards = null, - attachments = null, - replyMessage = when { - replyToCmId != null -> messages.value.find { it.cmId == replyToCmId } - else -> null - }, - geoType = null, - user = VkMemoryCache.getUser(UserConfig.userId), - group = null, - actionUser = null, - actionGroup = null, - isPinned = false, - isSpam = false, - pinnedAt = null, - formatData = formatData, - ) - formatData = formatData.copy(items = emptyList()) - sendingMessages += newMessage - messages.setValue { old -> listOf(newMessage).plus(old) } - syncUiMessages() - - screenState.setValue { old -> - old.copy( - message = TextFieldValue(), - actionMode = ActionMode.RECORD_AUDIO, - replyTitle = null, - replyText = null - ) - } - - val replyCmId = replyToCmId - replyToCmId = null - - val forward = when { - replyCmId != null -> { - buildJsonObject { - put("peer_id", screenState.value.convoId) - put("conversation_message_ids", buildJsonArray { add(replyCmId) }) - put("is_reply", true) - }.toString() - } - - else -> null - } - - messagesUseCase.sendMessage( - peerId = screenState.value.convoId, - randomId = newMessage.randomId, - message = newMessage.text, - forward = forward, - attachments = null, - formatData = newMessage.formatData, - ).listenValue(viewModelScope) { state -> - state.processState( - any = { sendingMessages.remove(newMessage) }, - error = { error -> - Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error") - - val failedId = -500_000L - failedMessages.size - val newFailedMessage = newMessage.copy(id = failedId) - failedMessages += newFailedMessage - - val newMessages = messages.value.toMutableList() - newMessages[newMessages.indexOf(newMessage)] = newFailedMessage - messages.setValue { newMessages } - syncUiMessages() - }, - success = { response -> - val newMessages = messages.value.toMutableList() - newMessages[newMessages.indexOf(newMessage)] = newMessage.copy( - id = response.messageId, - cmId = response.cmId - ) - messages.setValue { newMessages } - syncUiMessages() - } - ) - } - } - - private fun confirmDeleteCurrentEditMessage() { - val currentMessage = editMessage ?: return - - this.dialog.setValue { - MessageDialog.MessageDelete(currentMessage) - } - } - - private fun editCurrentEditMessage() { - replyToCmId = null - - val newText = screenState.value.message.text - - val lastText = lastMessageText.orEmpty().trim() - - screenState.setValue { old -> - old.copy( - editCmId = null, - message = TextFieldValue( - text = lastText, - selection = TextRange(lastText.length) - ), - actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO - else ActionMode.SEND, - - // TODO: 13/03/2026, Danil Nikolaev: save last reply - replyTitle = null, - replyText = null - ) - } - - syncUiMessages() - - // TODO: 13/03/2026, Danil Nikolaev: actually edit message - - val newMessage = editMessage?.copy( - replyMessage = if (replyToCmId == null) null else editMessage?.replyMessage, - text = newText - ) ?: return - - // TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit - - Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage") - } - - private fun markAsImportant( - messageIds: List, - important: Boolean, - ) { - messagesUseCase.markAsImportant( - peerId = screenState.value.convoId, - messageIds = messageIds, - important = important - ).listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { - val newMessages = messages.value - .toMutableList() - .map { message -> - if (message.id in messageIds) { - message.copy(isImportant = important) - } else { - message - } - } - messages.setValue { newMessages } - syncUiMessages() - } - ) - } - } - - private fun deleteMessage( - messageIds: List, - spam: Boolean = false, - deleteForAll: Boolean = false, - onSuccess: () -> Unit = {} - ) { - messagesUseCase.delete( - peerId = screenState.value.convoId, - messageIds = messageIds, - spam = spam, - deleteForAll = deleteForAll - ).listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { - onSuccess() - val newMessages = messages.value.toMutableList() - val messagesToDelete = newMessages.filter { it.id in messageIds } - newMessages.removeAll(messagesToDelete) - messages.setValue { newMessages } - syncUiMessages() - } - ) - } - } - - private fun pinMessage(messageId: Long) { - messagesUseCase.pin( - peerId = screenState.value.convoId, - messageId = messageId, - cmId = null - ).listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { pinnedMessage -> - handlePinnedMessage(pinnedMessage) - - val newMessages = messages.value.toMutableList() - val index = newMessages.indexOfFirstOrNull { it.id == messageId } - - if (index == null) {// сообщения нет в списке - // pizdets - } else { - newMessages[index] = pinnedMessage - messages.setValue { newMessages } - syncUiMessages() - } - } - ) - } - } - - private fun unpinMessage(messageId: Long) { - messagesUseCase.unpin(screenState.value.convoId) - .listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { - val newMessages = messages.value.toMutableList() - val index = newMessages.indexOfFirstOrNull { it.id == messageId } - - if (index == null) { // сообщения нет в списке - // pizdets - } else { - newMessages[index] = newMessages[index].copy(isPinned = false) - messages.setValue { newMessages } - syncUiMessages() - } - - handlePinnedMessage(null) - } - ) - } - } - - private fun readMessage(message: VkMessage) { - messagesUseCase.markAsRead( - peerId = screenState.value.convoId, - startMessageId = message.id - ).listenValue(viewModelScope) { state -> - state.processState( - error = ::handleError, - success = { - val oldConvo = screenState.value.convo - val newConvo = oldConvo.copy( - inRead = - if (!message.isOut) message.id - else oldConvo.inRead, - outRead = - if (message.isOut) message.id - else oldConvo.outRead - ) - - screenState.setValue { old -> - old.copy(convo = newConvo) - } - - syncUiMessages() - } - ) - } - } - - private fun copyMessage(message: VkMessage) { - val clipboardManager = - applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - - val messageToCopy = message.text.orEmpty().trim() - if (messageToCopy.isEmpty()) { - val photo = with(message.attachments.orEmpty()) { - if (size == 1 && all { it is VkPhotoDomain }) { - first() as? VkPhotoDomain - } else null - } ?: return - - val photoMaxSize = photo.getMaxSize() ?: return - - viewModelScope.launch(Dispatchers.IO) { - val drawable = applicationContext.imageLoader.execute( - ImageRequest.Builder(applicationContext) - .data(photoMaxSize.url) - .build() - ).drawable ?: return@launch - - val imagesDir = File(applicationContext.cacheDir, "images") - if (!imagesDir.exists()) imagesDir.mkdirs() - val imageFile = File(imagesDir, "shared_image_id${photo.id}.png") - FileOutputStream(imageFile).use { - drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it) - } - - val uri = FileProvider.getUriForFile( - applicationContext, - "${applicationContext.packageName}.provider", - imageFile - ) - - val clip = ClipData.newUri(applicationContext.contentResolver, "Image", uri) - clipboardManager.setPrimaryClip(clip) - - withContext(Dispatchers.Main) { - Toast.makeText( - applicationContext, - "Image copied to clipboard", - Toast.LENGTH_SHORT - ).show() - } - } - return - } - - clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", messageToCopy)) - - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) { - Toast.makeText(applicationContext, R.string.copied_to_clipboard, Toast.LENGTH_SHORT) - .show() - } - } - private fun syncUiMessages(): List { val newUiMessages = buildMessagesHistoryUiMessages( messages = messages.value,