refactor: split message transport actions

This commit is contained in:
Codex
2026-05-14 18:24:46 +03:00
parent 96f45aef6a
commit f24eae8209
3 changed files with 241 additions and 214 deletions
@@ -1,12 +1,6 @@
package dev.meloda.fast.messageshistory 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.util.Log
import android.widget.Toast
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
@@ -15,21 +9,12 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration 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.listenValue
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.extensions.removeIfCompat import dev.meloda.fast.common.extensions.removeIfCompat
import dev.meloda.fast.common.extensions.setValue import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache 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.MessagesUseCase
import dev.meloda.fast.domain.util.extractReplySummary import dev.meloda.fast.domain.util.extractReplySummary
import dev.meloda.fast.domain.util.extractReplyTitle import dev.meloda.fast.domain.util.extractReplyTitle
@@ -37,32 +22,22 @@ import dev.meloda.fast.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.ActionMode import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog import dev.meloda.fast.messageshistory.model.MessageDialog
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState 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.FormatDataType
import dev.meloda.fast.model.api.domain.VkMessage 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.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import java.io.File
import java.io.FileOutputStream
import kotlin.random.Random import kotlin.random.Random
internal class MessagesHistoryMessageActions( internal class MessagesHistoryMessageActions(
private val applicationContext: Context,
private val viewModelScope: CoroutineScope, private val viewModelScope: CoroutineScope,
private val messagesUseCase: MessagesUseCase, private val messagesUseCase: MessagesUseCase,
private val resourceProvider: ResourceProvider, private val resourceProvider: ResourceProvider,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>, private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>, private val messages: MutableStateFlow<List<VkMessage>>,
private val baseError: MutableStateFlow<BaseError?>,
private val showKeyboard: MutableStateFlow<Boolean>, private val showKeyboard: MutableStateFlow<Boolean>,
private val dialog: MutableStateFlow<MessageDialog?>, private val dialog: MutableStateFlow<MessageDialog?>,
private val syncUiMessages: () -> Unit, private val syncUiMessages: () -> Unit,
@@ -285,180 +260,6 @@ internal class MessagesHistoryMessageActions(
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage") Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
} }
fun markAsImportant(messageIds: List<Long>, 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<Long>,
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) { private fun updateFormatting(type: FormatDataType) {
val selectionRange = screenState.value.message.selection val selectionRange = screenState.value.message.selection
val newItems = formatData.items.toMutableList() val newItems = formatData.items.toMutableList()
@@ -512,9 +313,4 @@ internal class MessagesHistoryMessageActions(
} }
} }
private fun handleError(error: State.Error) {
VkUtils.parseError(error)?.let { newBaseError ->
baseError.setValue { newBaseError }
}
}
} }
@@ -0,0 +1,222 @@
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.widget.Toast
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.setValue
import dev.meloda.fast.data.State
import dev.meloda.fast.data.VkUtils
import dev.meloda.fast.data.processState
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.BaseError
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
internal class MessagesHistoryMessageTransportActions(
private val applicationContext: Context,
private val viewModelScope: CoroutineScope,
private val messagesUseCase: MessagesUseCase,
private val screenState: MutableStateFlow<MessagesHistoryScreenState>,
private val messages: MutableStateFlow<List<VkMessage>>,
private val baseError: MutableStateFlow<BaseError?>,
private val syncUiMessages: () -> Unit,
private val onPinnedMessageChanged: (VkMessage?) -> Unit
) {
fun markAsImportant(messageIds: List<Long>, 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<Long>,
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 handleError(error: State.Error) {
VkUtils.parseError(error)?.let { newBaseError ->
baseError.setValue { newBaseError }
}
}
}
@@ -66,19 +66,28 @@ class MessagesHistoryViewModelImpl(
) )
private val messageActions = MessagesHistoryMessageActions( private val messageActions = MessagesHistoryMessageActions(
applicationContext = applicationContext,
viewModelScope = viewModelScope, viewModelScope = viewModelScope,
messagesUseCase = messagesUseCase, messagesUseCase = messagesUseCase,
resourceProvider = resourceProvider, resourceProvider = resourceProvider,
screenState = screenState, screenState = screenState,
messages = messages, messages = messages,
baseError = baseError,
showKeyboard = showKeyboard, showKeyboard = showKeyboard,
dialog = dialog, dialog = dialog,
syncUiMessages = ::syncUiMessages, syncUiMessages = ::syncUiMessages,
onPinnedMessageChanged = pinnedMessageHandler::update onPinnedMessageChanged = pinnedMessageHandler::update
) )
private val messageTransportActions = MessagesHistoryMessageTransportActions(
applicationContext = applicationContext,
viewModelScope = viewModelScope,
messagesUseCase = messagesUseCase,
screenState = screenState,
messages = messages,
baseError = baseError,
syncUiMessages = ::syncUiMessages,
onPinnedMessageChanged = pinnedMessageHandler::update
)
private val loaders = MessagesHistoryLoaders( private val loaders = MessagesHistoryLoaders(
convoUseCase = convoUseCase, convoUseCase = convoUseCase,
messagesUseCase = messagesUseCase, messagesUseCase = messagesUseCase,
@@ -151,7 +160,7 @@ class MessagesHistoryViewModelImpl(
return return
} }
messageActions.deleteMessage( messageTransportActions.deleteMessage(
messageIds = listOf(dialog.message.id), messageIds = listOf(dialog.message.id),
deleteForAll = deleteForEveryone deleteForAll = deleteForEveryone
) )
@@ -166,7 +175,7 @@ class MessagesHistoryViewModelImpl(
.filter { it.id > 0 } .filter { it.id > 0 }
.map(VkMessage::id) .map(VkMessage::id)
messageActions.deleteMessage( messageTransportActions.deleteMessage(
messageIds = messageIdsToDelete, messageIds = messageIdsToDelete,
deleteForAll = deleteForEveryone, deleteForAll = deleteForEveryone,
onSuccess = { onSuccess = {
@@ -180,15 +189,15 @@ class MessagesHistoryViewModelImpl(
} }
is MessageDialog.MessagePin -> { is MessageDialog.MessagePin -> {
messageActions.pinMessage(dialog.messageId) messageTransportActions.pinMessage(dialog.messageId)
} }
is MessageDialog.MessageUnpin -> { is MessageDialog.MessageUnpin -> {
messageActions.unpinMessage(dialog.messageId) messageTransportActions.unpinMessage(dialog.messageId)
} }
is MessageDialog.MessageMarkImportance -> { is MessageDialog.MessageMarkImportance -> {
messageActions.markAsImportant( messageTransportActions.markAsImportant(
messageIds = listOf(dialog.message.id), messageIds = listOf(dialog.message.id),
important = dialog.isImportant important = dialog.isImportant
) )
@@ -196,7 +205,7 @@ class MessagesHistoryViewModelImpl(
is MessageDialog.MessageSpam -> { is MessageDialog.MessageSpam -> {
if (dialog.isSpam) { if (dialog.isSpam) {
messageActions.deleteMessage( messageTransportActions.deleteMessage(
messageIds = listOf(dialog.message.id), messageIds = listOf(dialog.message.id),
spam = true spam = true
) )
@@ -247,11 +256,11 @@ class MessagesHistoryViewModelImpl(
} }
MessageOption.Read -> { MessageOption.Read -> {
messageActions.readMessage(dialog.message) messageTransportActions.readMessage(dialog.message)
} }
MessageOption.Copy -> { MessageOption.Copy -> {
messageActions.copyMessage(dialog.message) messageTransportActions.copyMessage(dialog.message)
} }
MessageOption.MarkAsImportant, MessageOption.MarkAsImportant,