diff --git a/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt b/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt index ae59a6fe77..4cd99225bc 100644 --- a/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt +++ b/app/src/main/java/com/infomaniak/mail/receivers/NotificationActionsReceiver.kt @@ -39,13 +39,13 @@ import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.di.IoDispatcher +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.NotificationPayload import com.infomaniak.mail.utils.NotificationPayload.NotificationBehavior import com.infomaniak.mail.utils.NotificationPayload.NotificationBehavior.NotificationType import com.infomaniak.mail.utils.NotificationUtils -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getApiException import com.infomaniak.mail.utils.extensions.getUids @@ -86,7 +86,7 @@ class NotificationActionsReceiver : BroadcastReceiver() { lateinit var refreshController: RefreshController @Inject - lateinit var sharedUtils: SharedUtils + lateinit var messagesActionsUseCase: MessagesActionsUseCase @Inject @IoDispatcher @@ -171,7 +171,7 @@ class NotificationActionsReceiver : BroadcastReceiver() { val threads = message.threads.filter { it.folderId == message.folderId } val mailbox = mailboxController.getMailbox(userId, mailboxId) ?: return@launch - val messages = sharedUtils.getMessagesToMove(threads, message) + val messages = messagesActionsUseCase.getMessagesToMove(threads, message) val destinationFolder = folderController.getFolder(folderRole) ?: return@launch val okHttpClient = AccountUtils.getHttpClient(userId) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index e958c1ef49..c59f9a2525 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -69,11 +69,10 @@ import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.di.MailboxInfoRealm import com.infomaniak.mail.ui.main.SnackbarManager -import com.infomaniak.mail.ui.main.SnackbarManager.UndoData +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ContactUtils.getPhoneContacts import com.infomaniak.mail.utils.ContactUtils.mergeApiContactsIntoPhoneContacts -import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.MyKSuiteDataUtils import com.infomaniak.mail.utils.NetworkManager import com.infomaniak.mail.utils.NotificationUtils.Companion.cancelNotification @@ -86,10 +85,6 @@ import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.extensions.MergedContactDictionary import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext -import com.infomaniak.mail.utils.extensions.atLeastOneFailed -import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded -import com.infomaniak.mail.utils.extensions.getFoldersIds -import com.infomaniak.mail.utils.extensions.getUids import com.infomaniak.mail.utils.extensions.launchNoValidMailboxesActivity import com.infomaniak.mail.utils.toFolderUiTree import com.infomaniak.mail.views.itemViews.AvatarMergedContactData @@ -141,6 +136,7 @@ class MainViewModel @Inject constructor( private val mailboxController: MailboxController, private val mergedContactController: MergedContactController, private val messageController: MessageController, + private val messagesActionsUseCase: MessagesActionsUseCase, private val myKSuiteDataUtils: MyKSuiteDataUtils, private val networkManager: NetworkManager, private val permissionsController: PermissionsController, @@ -678,102 +674,9 @@ class MainViewModel @Inject constructor( } //endregion - //region Move - fun moveThreadsOrMessageTo( - destinationFolderId: String, - threadsUids: List, - messagesUid: List? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - val destinationFolder = folderController.getFolder(destinationFolderId)!! - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - val messages = messagesUid?.let { messageController.getMessages(it) } - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) - - moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, null, messagesToMove) - } - - private suspend fun moveThreadsOrMessageTo( - destinationFolder: Folder, - threadsUids: List, - threads: List, - message: Message? = null, - messagesToMove: List, - shouldDisplaySnackbar: Boolean = true, - ) { - val mailbox = currentMailbox.value!! - - moveOutThreadsLocally(threadsUids, threads, message) - - val apiResponses = moveMessages( - mailbox = mailbox, - messagesToMove = messagesToMove, - destinationFolder = destinationFolder, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlagsLive.value, localSettings), - ) - - if (apiResponses.atLeastOneSucceeded()) { - if (shouldAutoAdvance(message, threadsUids)) autoAdvanceThreadsUids.postValue(threadsUids) - - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messagesToMove.getFoldersIds(exception = destinationFolder.id), - destinationFolderId = destinationFolder.id, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(threadsUids) }), - ) - } - - if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) - - if (shouldDisplaySnackbar) showMoveSnackbar(threads, message, messagesToMove, apiResponses, destinationFolder) - } - - private suspend fun moveMessages( - mailbox: Mailbox, - messagesToMove: List, - destinationFolder: Folder, - alsoMoveReactionMessages: Boolean, - ): List> { - val apiResponses = ApiRepository.moveMessages( - mailboxUuid = mailbox.uuid, - messagesUids = messagesToMove.getUids(), - destinationId = destinationFolder.id, - alsoMoveReactionMessages = alsoMoveReactionMessages, - ) - - // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the - // same batch or in other batches that are target by emoji reactions did not - if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) - - return apiResponses - } - - /** - * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder - * that were targeting this message will display for a brief moment until we refresh their folders. This is because those - * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be - * displayed. - * - * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by - * the api at the same time anyway. - */ - private suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { - for (messageToMove in messagesToMove) { - if (messageToMove.emojiReactions.isEmpty()) continue - - mailboxContentRealm().write { - messageToMove.emojiReactions.forEach { reaction -> - reaction.authors.forEach { author -> - MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) - } - } - } - } - } - - private fun showMoveSnackbar( - threads: List, - message: Message?, - messages: List, + fun showMoveSnackbar( + threadsMovedCount: Int, + messagesMoved: List, apiResponses: List>, destinationFolder: Folder, ) { @@ -782,28 +685,17 @@ class MainViewModel @Inject constructor( val snackbarTitle = when { apiResponses.allFailed() -> appContext.getString(apiResponses.first().translateError()) - message == null -> appContext.resources.getQuantityString(R.plurals.snackbarThreadMoved, threads.count(), destination) - else -> appContext.getString(R.string.snackbarMessageMoved, destination) - } - - val undoResources = apiResponses.mapNotNull { it.data?.undoResource } - val undoData = if (undoResources.isEmpty()) { - null - } else { - val undoDestinationId = message?.folderId ?: threads.first().folderId - val foldersIds = messages.getFoldersIds(exception = undoDestinationId) - foldersIds += destinationFolder.id - UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, - foldersIds = foldersIds, - destinationFolderId = undoDestinationId, + threadsMovedCount > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( + R.plurals.snackbarThreadMoved, + threadsMovedCount, + destination ) + else -> appContext.getString(R.string.snackbarMessageMoved, destination) } + val undoData = messagesActionsUseCase.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } - //endregion - //region Display problem fun reportDisplayProblem(messageUid: String) = viewModelScope.launch(ioCoroutineContext) { @@ -880,30 +772,29 @@ class MainViewModel @Inject constructor( threadsUids: List, messagesUids: List?, ) = viewModelScope.launch(ioCoroutineContext) { - val newFolderId = createNewFolderSync(name) ?: return@launch - moveThreadsOrMessageTo(newFolderId, threadsUids, messagesUids) - isMovedToNewFolder.postValue(true) - } - //endregion + val newFolderId = createNewFolderSync(name) + val mailbox = currentMailbox.value + if (newFolderId == null || mailbox == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } - private suspend fun moveOutThreadsLocally( - threadsUids: List, - threads: List, - message: Message?, - ) { - val uidsToMove = if (message == null) { - threadsUids + val destinationFolder = folderController.getFolder(newFolderId) + if (destinationFolder == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } + + val result = + messagesActionsUseCase.moveThreadsOrMessagesTo(newFolderId, threadsUids, messagesUids, mailbox, currentFolderId) + if (result != null) { + showMoveSnackbar(threadsUids.count(), result.messages, result.apiResponses, destinationFolder) + isMovedToNewFolder.postValue(true) } else { - mutableListOf().apply { - threads.forEach { thread -> - var nbMessagesInCurrentFolder = thread.messages.count { it.folderId == currentFolderId } - if (message.folderId == currentFolderId) nbMessagesInCurrentFolder-- - if (nbMessagesInCurrentFolder == 0) add(thread.uid) - } - } + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) } - if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) } + //endregion private fun refreshFoldersAsync( mailbox: Mailbox, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt index 44e6b44186..e871b4babe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/SnackbarManager.kt @@ -91,7 +91,7 @@ class SnackbarManager @Inject constructor() { ) data class UndoData( - val resources: List, + val resources: List?, val foldersIds: ImpactedFolders, val destinationFolderId: String?, ) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index 42133c5b38..3559c3bef5 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt @@ -103,7 +103,7 @@ object PerformSwipeActionManager { handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder, currentMailbox) } SwipeAction.FAVORITE -> { - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) + actionsViewModel.toggleThreadsFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) true } SwipeAction.MOVE -> { @@ -129,7 +129,7 @@ object PerformSwipeActionManager { true } SwipeAction.READ_UNREAD -> { - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(thread.uid), currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox @@ -138,7 +138,7 @@ object PerformSwipeActionManager { } SwipeAction.SPAM -> { - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox @@ -173,7 +173,7 @@ object PerformSwipeActionManager { } fun onSuccess() { - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox @@ -206,7 +206,7 @@ object PerformSwipeActionManager { fun onHandleDelete() { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = currentMailBox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt index 86cad6bcbe..0ace6045da 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListMultiSelection.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2025 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -72,15 +72,16 @@ class ThreadListMultiSelection { threadListFragment.binding.quickActionBar.setOnItemClickListener { menuId -> val selectedThreadsUids = selectedThreads.map { it.uid } val selectedThreadsCount = selectedThreadsUids.count() + val currentMailBox = currentMailbox.value ?: return@setOnItemClickListener when (menuId) { R.id.quickActionUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedThreadsCount) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, currentFolderId = currentFolderId, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } @@ -90,19 +91,19 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } } R.id.quickActionFavorite -> { trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = selectedThreadsUids, - mailbox = currentMailbox.value!!, + mailbox = currentMailBox, shouldFavorite = shouldMultiselectFavorite ) isMultiSelectOn = false @@ -113,10 +114,10 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = selectedThreads.toList(), currentFolder = currentFolder.value, - mailbox = currentMailbox.value!! + mailbox = currentMailBox ) isMultiSelectOn = false } @@ -124,6 +125,7 @@ class ThreadListMultiSelection { R.id.quickActionMenu -> { trackMultiSelectActionEvent(MatomoName.OpenBottomSheet, selectedThreadsCount) val direction = if (selectedThreadsCount == 1) { + isMultiSelectOn = false ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( threadUid = selectedThreadsUids.single(), shouldLoadDistantResources = false, diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index 50c997ef5f..b3e6931e5b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt @@ -40,6 +40,7 @@ import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.databinding.FragmentFolderPickerBinding import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.CreateFolderDialog +import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.search.SearchViewModel import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.Utils @@ -53,6 +54,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject +import com.infomaniak.core.common.R as RCore @AndroidEntryPoint class FolderPickerFragment : Fragment() { @@ -70,6 +72,9 @@ class FolderPickerFragment : Fragment() { @Inject lateinit var folderPickerAdapter: FolderPickerAdapter + @Inject + lateinit var snackbarManager: SnackbarManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { @@ -150,25 +155,42 @@ class FolderPickerFragment : Fragment() { } } - private fun onFolderSelected(folder: Folder?): Unit = with(navigationArgs) { - when (action) { - FolderPickerAction.MOVE -> folder?.id?.let { - actionsViewModel.moveThreadsOrMessagesTo( - destinationFolderId = it, - threadsUids = threadsUids.toList(), - messagesUids = messagesUids?.toList(), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) - } + private fun onFolderSelected(folder: Folder?) { + when (navigationArgs.action) { + FolderPickerAction.MOVE -> handleMove(folder?.id) FolderPickerAction.SEARCH -> { searchViewModel.selectAllFoldersFilter(folder == null) searchViewModel.selectFolder(folder) } } + findNavController().popBackStack() } + private fun handleMove(folderId: String?) = with(navigationArgs) { + val mailbox = mainViewModel.currentMailbox.value + if (folderId == null || mailbox == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + return@with + } + + if (messagesUids != null) { + actionsViewModel.moveMessagesTo( + destinationFolderId = folderId, + messagesUids = messagesUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mailbox + ) + } else { + actionsViewModel.moveThreadsTo( + destinationFolderId = folderId, + threadsUids = threadsUids.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mailbox + ) + } + } + private fun setupSearchBar() = with(binding) { searchInputLayout.setOnClearTextClickListener { trackMoveSearchEvent(MatomoName.DeleteSearch) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index aed536b8ac..ed73e903f9 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -825,7 +825,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private fun initUi(threadUid: String, folderRole: FolderRole?) = with(binding) { iconFavorite.setOnClickListener { trackThreadActionsEvent(MatomoName.Favorite, threadViewModel.threadLive.value!!.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = listOf(threadUid), mailbox = mainViewModel.currentMailbox.value!! ) @@ -853,7 +853,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Archive, isFromArchive) val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -864,7 +864,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Delete) val thread = threadViewModel.threadLive.value ?: return@deleteWithConfirmationPopup - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt index 478ca23d90..012fbf227d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadViewModel.kt @@ -59,6 +59,7 @@ import com.infomaniak.mail.ui.main.thread.models.EmojiReactionAuthorUi import com.infomaniak.mail.ui.main.thread.models.EmojiReactionStateUi import com.infomaniak.mail.ui.main.thread.models.MessageUi import com.infomaniak.mail.ui.main.thread.models.MessageUi.UnsubscribeState +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.FeatureAvailability import com.infomaniak.mail.utils.FeatureAvailability.isSnoozeAvailable @@ -122,6 +123,7 @@ class ThreadViewModel @Inject constructor( private val mailboxContentRealm: RealmDatabase.MailboxContent, private val mailboxController: MailboxController, private val messageController: MessageController, + private val messagesActionsUseCase: MessagesActionsUseCase, private val refreshController: RefreshController, private val sharedUtils: SharedUtils, private val snackbarManager: SnackbarManager, @@ -174,15 +176,15 @@ class ThreadViewModel @Inject constructor( private val fakeReactions = MutableStateFlow>>(emptyMap()) /** - * Flow tracking the unsubscribe status of each message, keyed by its UID. - * - * Whenever a message is unsubscribed, its state (e.g., [UnsubscribeState.InProgress], [UnsubscribeState.Completed]) - * is updated in this map, and the entire map is re-emitted to trigger a refresh of the [MessageUi]. - * - * This flow is collected inside a [combine] to ensure that unsubscribe statuses are preserved - * even when other changes (e.g., sorting, filtering, content updates) trigger a full rebuild - * of the [MessageUi] list. - */ + * Flow tracking the unsubscribe status of each message, keyed by its UID. + * + * Whenever a message is unsubscribed, its state (e.g., [UnsubscribeState.InProgress], [UnsubscribeState.Completed]) + * is updated in this map, and the entire map is re-emitted to trigger a refresh of the [MessageUi]. + * + * This flow is collected inside a [combine] to ensure that unsubscribe statuses are preserved + * even when other changes (e.g., sorting, filtering, content updates) trigger a full rebuild + * of the [MessageUi] list. + */ private val unsubscribeStateByMessageUid = MutableStateFlow>(emptyMap()) @OptIn(ExperimentalCoroutinesApi::class) @@ -415,7 +417,7 @@ class ThreadViewModel @Inject constructor( } private fun markThreadAsSeen(thread: Thread) = viewModelScope.launch(ioCoroutineContext) { - sharedUtils.markAsSeen(mailbox(), listOf(thread)) + messagesActionsUseCase.markAsSeen(mailbox(), listOf(thread)) } private fun sendMatomoAboutThreadMessagesCount(thread: Thread, featureFlags: Mailbox.FeatureFlagSet) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt index e8c4d0a85e..716560abbe 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -25,45 +25,33 @@ import com.infomaniak.core.legacy.utils.SingleLiveEvent import com.infomaniak.core.network.models.ApiResponse import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.mail.R -import com.infomaniak.mail.data.LocalSettings -import com.infomaniak.mail.data.api.ApiRepository -import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.ThreadController -import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.MoveResult -import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox -import com.infomaniak.mail.data.models.mailbox.SendersRestrictions import com.infomaniak.mail.data.models.message.Message import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.di.IoDispatcher import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.main.SnackbarManager.UndoData -import com.infomaniak.mail.utils.FeatureAvailability +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.FolderRoleUtils -import com.infomaniak.mail.utils.SharedUtils -import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear import com.infomaniak.mail.utils.extensions.allFailed import com.infomaniak.mail.utils.extensions.appContext -import com.infomaniak.mail.utils.extensions.atLeastOneFailed import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded -import com.infomaniak.mail.utils.extensions.getFirstTranslatedError -import com.infomaniak.mail.utils.extensions.getFoldersIds import com.infomaniak.mail.utils.extensions.getUids import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.invoke import kotlinx.coroutines.launch import java.util.Date import javax.inject.Inject @@ -75,11 +63,8 @@ class ActionsViewModel @Inject constructor( application: Application, private val folderController: FolderController, private val folderRoleUtils: FolderRoleUtils, - private val localSettings: LocalSettings, - private val mailboxContentRealm: RealmDatabase.MailboxContent, - private val mailboxController: MailboxController, private val messageController: MessageController, - private val sharedUtils: SharedUtils, + private val messagesActionsUseCase: MessagesActionsUseCase, private val snackbarManager: SnackbarManager, private val threadController: ThreadController, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -96,181 +81,104 @@ class ActionsViewModel @Inject constructor( fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUid) - toggleMessagesSpamStatus(messages, currentFolderId, mailbox) + handleToggleSpamMessages(messages, currentFolderId, mailbox, displaySnackbar = true) } } - fun toggleThreadsOrMessagesSpamStatus( - messages: List? = null, - threads: Set? = null, + fun toggleThreadsSpamStatus( + threads: Set, currentFolderId: String?, mailbox: Mailbox, - displaySnackbar: Boolean = true + displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMarkAsSpam = when { - threads != null -> getMessagesFromThreadToSpamOrHam(threads) - messages != null -> messageController.getUnscheduledMessages(messages) - else -> emptyList() - } - - toggleMessagesSpamStatus(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) - } - - fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - ApiRepository.setSpamFilter( - mailboxHostingId = mailbox.hostingId, - mailboxName = mailbox.mailboxName, - activateSpamFilter = true, - ) + val messagesToMarkAsSpam = messagesActionsUseCase.getMessagesFromThreadToSpamOrHam(threads) + handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) } - fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { - if (mailbox == null) return@launch - - with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { - if (isSuccess()) { - val restrictions = data - if (restrictions == null) { - snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return@launch - } - restrictions.blockedSenders.removeIf { it.email == email } - - updateBlockedSenders(mailbox, restrictions) - } - } - } - - private fun toggleMessagesSpamStatus( + fun toggleMessagesSpamStatus( messages: List, currentFolderId: String?, mailbox: Mailbox, displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMarkAsSpam = messageController.getUnscheduledMessages(messages) + handleToggleSpamMessages(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) + } - val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null - val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) + private suspend fun handleToggleSpamMessages( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true, + ) { + val result = messagesActionsUseCase.toggleMessagesSpamStatus( + messages = messages, + currentFolderId = currentFolderId, + mailbox = mailbox, + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), + ) - val destinationFolderRole = if (folderRole == FolderRole.SPAM) { - FolderRole.INBOX - } else { - FolderRole.SPAM + if (displaySnackbar && result != null) { + showMoveSnackbar(result.movedThreads, result.messages, result.apiResponses, result.destinationFolder) } - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - - val unscheduledMessages = messageController.getUnscheduledMessages(messages) - - moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduledMessages, displaySnackbar) } - private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { - return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } + fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + messagesActionsUseCase.activateSpamFilter(mailbox) } - private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { - with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { - if (isSuccess()) { - mailboxController.updateMailbox(mailbox.objectId) { - it.sendersRestrictions = updatedSendersRestrictions - } - } + fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { + if (mailbox == null) return@launch + + val result = messagesActionsUseCase.unblockMail(email, mailbox) + if (result is MessagesActionsUseCase.ApiCallResult.Error) { + snackbarManager.postValue(appContext.getString(result.messageRes)) } } //endregion //region Move - fun moveThreadsOrMessagesTo( + fun moveThreadsTo( destinationFolderId: String, - threadsUids: List? = null, - messagesUids: List? = null, + threadsUids: List, currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch - val threads: List? = threadsUids?.let { threadController.getThreads(threadsUids).toList() } - val messages = messagesUids?.let { messageController.getMessages(it) } - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) + val threads: List = threadController.getThreads(threadsUids).toList() + val messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) - moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) + handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } - private suspend fun moveMessagesTo( - destinationFolder: Folder, + fun moveMessagesTo( + destinationFolderId: String, + messagesUids: List, currentFolderId: String?, mailbox: Mailbox, - messages: List, - shouldDisplaySnackbar: Boolean = true, - ) { - - val movedThreads = moveOutThreadsLocally(messages, destinationFolder) - val featureFlags = mailbox.featureFlags - - val apiResponses = moveMessages( - mailbox = mailbox, - messagesToMove = messages, - destinationFolder = destinationFolder, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), - ) - - if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { - - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), - destinationFolderId = destinationFolder.id, - currentFolderId = currentFolderId, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(movedThreads) }), - ) - } - - if (apiResponses.atLeastOneFailed() && movedThreads.isNotEmpty()) { - threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) - } + ) = viewModelScope.launch(ioCoroutineContext) { + val messages = messageController.getMessages(messagesUids) + val messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolderId) - if (shouldDisplaySnackbar) showMoveSnackbar(movedThreads, messages, apiResponses, destinationFolder) + handleMessagesMove(destinationFolderId, messagesToMove, currentFolderId, mailbox) } - private suspend fun moveMessages( + private suspend fun handleMessagesMove( + destinationFolderId: String, + messages: List, + currentFolderId: String?, mailbox: Mailbox, - messagesToMove: List, - destinationFolder: Folder, - alsoMoveReactionMessages: Boolean, - ): List> { - val apiResponses = ApiRepository.moveMessages( - mailboxUuid = mailbox.uuid, - messagesUids = messagesToMove.getUids(), - destinationId = destinationFolder.id, - alsoMoveReactionMessages = alsoMoveReactionMessages, + ) { + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return + + val result = messagesActionsUseCase.moveMessagesTo( + destinationFolder = destinationFolder, + currentFolderId = currentFolderId, + mailbox = mailbox, + messages = messages, + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), ) - // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the - // same batch or in other batches that are target by emoji reactions did not - if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) - - return apiResponses - } - - /** - * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder - * that were targeting this message will display for a brief moment until we refresh their folders. This is because those - * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be - * displayed. - * - * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by - * the api at the same time anyway. - */ - private suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { - for (messageToMove in messagesToMove) { - if (messageToMove.emojiReactions.isEmpty()) continue - - mailboxContentRealm().write { - messageToMove.emojiReactions.forEach { reaction -> - reaction.authors.forEach { author -> - MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) - } - } - } - } + showMoveSnackbar(result.movedThreads, result.messages, result.apiResponses, result.destinationFolder) } private fun showMoveSnackbar( @@ -287,103 +195,83 @@ class ActionsViewModel @Inject constructor( threadsMoved.count() > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( R.plurals.snackbarThreadMoved, threadsMoved.count(), - destination + destination, ) else -> appContext.getString(R.string.snackbarMessageMoved, destination) } - val undoResources = apiResponses.mapNotNull { it.data?.undoResource } - val undoData = if (undoResources.isEmpty()) { - null - } else { - val undoDestinationId = destinationFolder.id - val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) - foldersIds += destinationFolder.id - UndoData( - resources = apiResponses.mapNotNull { it.data?.undoResource }, - foldersIds = foldersIds, - destinationFolderId = undoDestinationId, - ) - } - + val undoData = messagesActionsUseCase.getUndoData(messagesMoved, apiResponses, destinationFolder) snackbarManager.postValue(snackbarTitle, undoData) } //endregion //region Delete - fun deleteThreadsOrMessages( - threads: List? = null, - messages: List? = null, + fun deleteThreads( + threads: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToDelete = getMessagesToDelete(threads, messages) - deleteMessages(messagesToDelete, currentFolder, mailbox) + val messagesToDelete = messagesActionsUseCase.getMessagesFromThreadToDelete(threads) + handleDeleteMessages(messagesToDelete, currentFolder, mailbox) } - private fun deleteMessages( + fun deleteMessages( messages: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val shouldPermanentlyDelete = isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messages, currentFolder)) - - if (shouldPermanentlyDelete) { - permanentlyDelete(messages, currentFolder, mailbox) - } else { - moveMessagesTo( - destinationFolder = folderController.getFolder(FolderRole.TRASH)!!, - messages = messages, - currentFolderId = currentFolder?.id, - mailbox = mailbox - ) - } + val messagesToDelete = messagesActionsUseCase.getMessagesToDelete(messages) + handleDeleteMessages(messagesToDelete, currentFolder, mailbox) } - private suspend fun permanentlyDelete(messagesToDelete: List, currentFolder: Folder?, mailbox: Mailbox) { - val undoResources = emptyList() - val uids = messagesToDelete.getUids() + private suspend fun handleDeleteMessages( + messagesToDelete: List, + currentFolder: Folder?, + mailbox: Mailbox, + ) { + val shouldPermanentlyDelete = + isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messagesToDelete, currentFolder)) - val uidsToMove = moveOutThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) + if (shouldPermanentlyDelete) { + val result = messagesActionsUseCase.permanentlyDelete( + messagesToDelete = messagesToDelete, + currentFolder = currentFolder, + mailbox = mailbox, + onApiFinished = { activityDialogLoaderResetTrigger.postValue(Unit) }, + refreshCallbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = ::onDownloadStop), + ) - val apiResponses = ApiRepository.deleteMessages( - mailboxUuid = mailbox.uuid, - messagesUids = uids, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings) - ) + if (result != null) { + showDeleteSnackbar( + apiResponses = result.apiResponses, + messages = messagesToDelete, + undoResources = result.undoResources, + undoFoldersIds = result.undoFoldersIds, + undoDestinationId = result.undoDestinationId, + numberOfImpactedThreads = messagesToDelete.count(), + ) + } - activityDialogLoaderResetTrigger.postValue(Unit) + } else { + val destinationFolder = folderController.getFolder(FolderRole.TRASH) + if (destinationFolder == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return + } - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messagesToDelete.getFoldersIds(), + moveMessagesTo( + destinationFolderId = destinationFolder.id, + messagesUids = messagesToDelete.getUids(), currentFolderId = currentFolder?.id, - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(uidsToMove) }), + mailbox = mailbox, ) } - - if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( - threadsUids = uidsToMove, - hasBeenMovedOut = false - ) - - val undoDestinationId = messagesToDelete.first().folderId - val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) - showDeleteSnackbar( - apiResponses = apiResponses, - messages = messagesToDelete, - undoResources = undoResources, - undoFoldersIds = undoFoldersIds, - undoDestinationId = undoDestinationId, - numberOfImpactedThreads = messagesToDelete.count(), - ) } private fun showDeleteSnackbar( apiResponses: List>, messages: List, - undoResources: List, + undoResources: List?, undoFoldersIds: ImpactedFolders, undoDestinationId: String?, numberOfImpactedThreads: Int, @@ -392,7 +280,7 @@ class ActionsViewModel @Inject constructor( if (messages.count() > 1) { appContext.resources.getQuantityString( R.plurals.snackbarThreadDeletedPermanently, - numberOfImpactedThreads + numberOfImpactedThreads, ) } else { appContext.getString(R.string.snackbarMessageDeletedPermanently) @@ -401,214 +289,113 @@ class ActionsViewModel @Inject constructor( appContext.getString(apiResponses.first().translateError()) } - val undoData = if (undoResources.isEmpty()) null else UndoData(undoResources, undoFoldersIds, undoDestinationId) + val undoData = if (undoResources.isNullOrEmpty()) null else UndoData(undoResources, undoFoldersIds, undoDestinationId) snackbarManager.postValue(snackbarTitle, undoData) } - private suspend fun getMessagesToDelete(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() - } - //endregion //region Archive - - fun archiveThreadsOrMessages( - threads: List? = null, - messages: List? = null, + fun archiveThreads( + threads: List, currentFolder: Folder?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolder?.id) - archiveMessages(messagesToMove, currentFolder, mailbox) + val messagesToMove = messagesActionsUseCase.getMessagesFromThreadsToMove(threads) + handleArchiveMessage(messagesToMove, currentFolder, mailbox) } - private fun archiveMessages( + fun archiveMessages( messages: List, currentFolder: Folder?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMove = messagesActionsUseCase.getMessagesToMove(messages, currentFolder?.id) + handleArchiveMessage(messagesToMove, currentFolder, mailbox) + } + private suspend fun handleArchiveMessage( + messages: List, + currentFolder: Folder?, + mailbox: Mailbox, + ) { val role = folderRoleUtils.getActionFolderRole(messages, currentFolder) val isFromArchive = role == FolderRole.ARCHIVE val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE - val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return@launch + val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return - moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) + moveMessagesTo(destinationFolder.id, messages.getUids(), currentFolder?.id, mailbox) } //region Seen - fun toggleThreadsOrMessagesSeenStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleThreadsSeenStatus( + threadsUids: List, shouldRead: Boolean = true, currentFolderId: String?, - mailbox: Mailbox - ) { - toggleMessagesSeenStatus(threadsUids, messages, shouldRead = shouldRead, currentFolderId, mailbox) + mailbox: Mailbox, + ) = viewModelScope.launch(ioCoroutineContext) { + val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleThreadSeenStatus(threadsUids, shouldRead, currentFolderId, mailbox, refreshCallbacks) } - private fun toggleMessagesSeenStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleMessagesSeenStatus( + messages: List, shouldRead: Boolean = true, currentFolderId: String?, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val threads = threadsUids?.let { threadController.getThreads(threadsUids) } - val isSeen = when { - messages?.count() == 1 -> messages.single().isSeen - threads?.count() == 1 -> threads.single().isSeen - else -> !shouldRead - } - - val messagesToToggleSeen = getMessagesToMarkAsUnseen(threads, messages, mailbox) - - if (isSeen) { - markAsUnseen(messagesToToggleSeen, mailbox) - } else { - sharedUtils.markMessagesAsSeen( - messages = messagesToToggleSeen, - currentFolderId = currentFolderId, - mailbox = mailbox, - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } + val refreshCallbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleMessagesSeenStatus(messages, shouldRead, currentFolderId, mailbox, refreshCallbacks) } - private suspend fun markAsUnseen(messages: List, mailbox: Mailbox) { - val messagesUids = messages.map { it.uid } - - sharedUtils.updateSeenStatus(messagesUids, isSeen = false) - - val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } else { - sharedUtils.updateSeenStatus(messagesUids, isSeen = true) - } - } - - private suspend fun getMessagesToMarkAsUnseen( - threads: List?, - messages: List?, - mailbox: Mailbox - ): List = when { - threads != null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) - } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() //this should never happen, we should always send a list of messages or threads. - } //endregion //region Favorite - fun toggleThreadsOrMessagesFavoriteStatus( - threadsUids: List? = null, - messages: List? = null, + fun toggleThreadsFavoriteStatus( + threadsUids: List, shouldFavorite: Boolean = true, - mailbox: Mailbox + mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val threads = threadsUids?.let { threadController.getThreads(threadsUids) } - - val isFavorite = when { - messages?.count() == 1 -> messages.single().isFavorite - threads?.count() == 1 -> threads.single().isFavorite - else -> !shouldFavorite - } - - val messages = if (isFavorite) { - getMessagesToUnfavorite(threads, messages) - } else { - getMessagesToFavorite(threads, messages, mailbox) - } - - toggleMessagesFavoriteStatus(messages, isFavorite, mailbox) - } - - private fun toggleMessagesFavoriteStatus(messages: List, isFavorite: Boolean, mailbox: Mailbox) { - viewModelScope.launch(ioCoroutineContext) { - val uids = messages.getUids() - - updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) - - val apiResponses = if (isFavorite) { - ApiRepository.removeFromFavorites(mailbox.uuid, uids) - } else { - ApiRepository.addToFavorites(mailbox.uuid, uids) - } - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } else { - updateFavoriteStatus(messagesUids = uids, isFavorite = isFavorite) - } - } + val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleThreadFavorite(threadsUids, shouldFavorite, mailbox, callbacks) } - private suspend fun getMessagesToFavorite(threads: List?, messages: List?, mailbox: Mailbox) = when { - threads != null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) - } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() // this should never happen, we should always pass threads or messages - } - - private suspend fun getMessagesToUnfavorite(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { messageController.getFavoriteMessages(it) } - messages != null -> messageController.getMessagesAndDuplicates(messages) - else -> emptyList() - } - - private suspend fun updateFavoriteStatus(messagesUids: List, isFavorite: Boolean) { - mailboxContentRealm().write { - MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) - } + fun toggleMessagesFavoriteStatus( + messages: List, + shouldFavorite: Boolean = true, + mailbox: Mailbox, + ) = viewModelScope.launch(ioCoroutineContext) { + val callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop) + messagesActionsUseCase.toggleMessagesFavorite(messages, shouldFavorite, mailbox, callbacks) } //endregion //region Phishing fun reportPhishing(messages: List, currentFolder: Folder?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = mailbox.uuid - val messagesUids: List = messages.map { it.uid } - - if (messagesUids.isEmpty()) { - snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) - return@launch - } - - with(ApiRepository.reportPhishing(mailboxUuid, messagesUids)) { - val snackbarTitle = if (isSuccess()) { - - if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { - toggleThreadsOrMessagesSpamStatus( - messages = messages, - currentFolderId = currentFolder?.id, - mailbox = mailbox, - displaySnackbar = false - ) - } - - R.string.snackbarReportPhishingConfirmation - } else { - translateError() + val result = messagesActionsUseCase.reportPhishing( + messages = messages, + currentFolder = currentFolder, + mailbox = mailbox, + onReportSuccess = { + toggleMessagesSpamStatus( + messages = messages, + currentFolderId = currentFolder?.id, + mailbox = mailbox, + displaySnackbar = false, + ) } + ) - reportPhishingTrigger.postValue(Unit) - snackbarManager.postValue(appContext.getString(snackbarTitle)) + when (result) { + is MessagesActionsUseCase.ApiCallResult.Success -> { + reportPhishingTrigger.postValue(Unit) + snackbarManager.postValue(appContext.getString(result.messageRes)) + } + is MessagesActionsUseCase.ApiCallResult.Error -> { + snackbarManager.postValue(appContext.getString(result.messageRes)) + } } } } @@ -616,12 +403,15 @@ class ActionsViewModel @Inject constructor( //region BlockUser fun blockUser(folderId: String, shortUid: Int, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = mailbox.uuid - with(ApiRepository.blockUser(mailboxUuid, folderId, shortUid)) { - val snackbarTitle = if (isSuccess()) R.string.snackbarBlockUserConfirmation else translateError() - snackbarManager.postValue(appContext.getString(snackbarTitle)) - - reportPhishingTrigger.postValue(Unit) + val result = messagesActionsUseCase.blockUser(folderId, shortUid, mailbox) + when (result) { + is MessagesActionsUseCase.ApiCallResult.Success -> { + reportPhishingTrigger.postValue(Unit) + snackbarManager.postValue(appContext.getString(result.messageRes)) + } + is MessagesActionsUseCase.ApiCallResult.Error -> { + snackbarManager.postValue(appContext.getString(result.messageRes)) + } } } //endregion @@ -629,106 +419,62 @@ class ActionsViewModel @Inject constructor( //region Snooze // For now we only do snooze for Threads. suspend fun snoozeThreads(date: Date, threadUids: List, currentFolderId: String?, mailbox: Mailbox?): Boolean { - var isSuccess = false + if (mailbox == null) return false - viewModelScope.launch { - mailbox?.let { currentMailbox -> - val threads = threadUids.mapNotNull { threadController.getThread(it) } - - val messageUids = threads.mapNotNull { thread -> - thread.getDisplayedMessages(currentMailbox.featureFlags, localSettings) - .lastOrNull { it.folderId == currentFolderId }?.uid - } - - val responses = ioDispatcher { ApiRepository.snoozeMessages(currentMailbox.uuid, messageUids, date) } - - isSuccess = responses.atLeastOneSucceeded() - val userFeedbackMessage = if (isSuccess) { - // Snoozing threads requires to refresh the snooze folder. - // It's the only folder that will update the snooze state of any message. - refreshFoldersAsync(currentMailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) - - val formattedDate = appContext.dayOfWeekDateWithoutYear(date) - appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threads.count(), formattedDate) - } else { - val errorMessageRes = responses.getFirstTranslatedError() ?: RCore.string.anErrorHasOccurred - appContext.getString(errorMessageRes) - } + val result = messagesActionsUseCase.snoozeThreads( + date = date, + threadUids = threadUids, + currentFolderId = currentFolderId, + mailbox = mailbox, + ) - snackbarManager.postValue(userFeedbackMessage) + val feedback = when (result) { + is MessagesActionsUseCase.SnoozeResult.Success -> { + val formattedDate = appContext.dayOfWeekDateWithoutYear(result.date) + appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, result.threadCount, formattedDate) } - }.join() + is MessagesActionsUseCase.SnoozeResult.Error -> appContext.getString(result.messageRes) + } - return isSuccess + snackbarManager.postValue(feedback) + return result is MessagesActionsUseCase.SnoozeResult.Success } suspend fun rescheduleSnoozedThreads(date: Date, threadUids: List, mailbox: Mailbox): BatchSnoozeResult { - var rescheduleResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown - - viewModelScope.launch(ioCoroutineContext) { - val snoozedThreadUuids = threadUids.mapNotNull { threadUid -> - val thread = threadController.getThread(threadUid) ?: return@mapNotNull null - thread.snoozeUuid.takeIf { thread.isSnoozed() } - } - if (snoozedThreadUuids.isEmpty()) return@launch - - val result = rescheduleSnoozedThreads(mailbox, snoozedThreadUuids, date) - - val userFeedbackMessage = when (result) { - is BatchSnoozeResult.Success -> { - refreshFoldersAsync(mailbox, result.impactedFolders) + val result = messagesActionsUseCase.rescheduleSnoozedThreads( + date = date, + threadUids = threadUids, + mailbox = mailbox, + ) - val formattedDate = appContext.dayOfWeekDateWithoutYear(date) - appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threadUids.count(), formattedDate) - } - is BatchSnoozeResult.Error -> getRescheduleSnoozedErrorMessage(result) + val feedback = when (result) { + is BatchSnoozeResult.Success -> { + val formattedDate = appContext.dayOfWeekDateWithoutYear(date) + appContext.resources.getQuantityString(R.plurals.snackbarSnoozeSuccess, threadUids.count(), formattedDate) } + is BatchSnoozeResult.Error -> getRescheduleSnoozedErrorMessage(result) + } - snackbarManager.postValue(userFeedbackMessage) - - rescheduleResult = result - }.join() - - return rescheduleResult + snackbarManager.postValue(feedback) + return result } - suspend fun unsnoozeThreads(threads: Collection, mailbox: Mailbox?): BatchSnoozeResult { - var unsnoozeResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown + fun unsnoozeThreads(threads: List, mailbox: Mailbox?) { + if (mailbox == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return + } viewModelScope.launch(ioCoroutineContext) { - unsnoozeResult = if (mailbox == null) { - BatchSnoozeResult.Error.Unknown - } else { - ioDispatcher { unsnoozeThreadsWithoutRefresh(scope = null, mailbox, threads) } - } - - unsnoozeResult.let { - val userFeedbackMessage = when (it) { - is BatchSnoozeResult.Success -> { - sharedUtils.refreshFolders(mailbox = mailbox!!, messagesFoldersIds = it.impactedFolders) - appContext.resources.getQuantityString(R.plurals.snackbarUnsnoozeSuccess, threads.count()) - } - is BatchSnoozeResult.Error -> getUnsnoozeErrorMessage(it) - } - - snackbarManager.postValue(userFeedbackMessage) + val result = messagesActionsUseCase.unsnoozeThreads(threads, mailbox) + val message = when (result) { + is BatchSnoozeResult.Success -> appContext.resources.getQuantityString( + R.plurals.snackbarUnsnoozeSuccess, threads.count() + ) + is BatchSnoozeResult.Error -> getUnsnoozeErrorMessage(result) } - }.join() - - return unsnoozeResult - } - - private suspend fun rescheduleSnoozedThreads( - currentMailbox: Mailbox, - snoozeUuids: List, - date: Date, - ): BatchSnoozeResult { - return SharedUtils.rescheduleSnoozedThreads( - mailboxUuid = currentMailbox.uuid, - snoozeUuids = snoozeUuids, - newDate = date, - impactedFolders = ImpactedFolders(mutableSetOf(FolderRole.SNOOZED)), - ) + snackbarManager.postValue(message) + } } private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { @@ -746,37 +492,21 @@ class ActionsViewModel @Inject constructor( is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred } - return appContext.getString(errorMessageRes) } //endregion //region Undo action - fun undoAction(undoData: UndoData, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - - fun List>.getFailedCall() = firstOrNull { it.data != true } - - val (resources, foldersIds, destinationFolderId) = undoData - - val apiResponses = resources.map { ApiRepository.undoAction(it) } - - if (apiResponses.atLeastOneSucceeded()) { - // Don't use `refreshFoldersAsync` here, it will make the Snackbars blink. - sharedUtils.refreshFolders( - mailbox = mailbox, - messagesFoldersIds = foldersIds, - destinationFolderId = destinationFolderId, - ) - } - - val failedCall = apiResponses.getFailedCall() + fun undoAction(undoData: UndoData?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + if (undoData == null) return@launch - val snackbarTitle = when { - failedCall == null -> R.string.snackbarMoveCancelled - else -> failedCall.translateError() + val result = messagesActionsUseCase.undoAction(undoData, mailbox) + val message = when (result) { + is MessagesActionsUseCase.ApiCallResult.Success -> appContext.getString(result.messageRes) + is MessagesActionsUseCase.ApiCallResult.Error -> appContext.getString(result.messageRes) } - snackbarManager.postValue(appContext.getString(snackbarTitle)) + snackbarManager.postValue(message) } //endregion @@ -788,26 +518,4 @@ class ActionsViewModel @Inject constructor( threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) isDownloadingChanges.postValue(false) } - - private fun refreshFoldersAsync( - mailbox: Mailbox, - messagesFoldersIds: ImpactedFolders, - currentFolderId: String? = null, - destinationFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) - } - - private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { - val uidsToMove = mutableListOf().apply { - messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> - val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } - if (nbMessagesInCurrentFolder == 0) add(thread.uid) - } - } - - if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) - return uidsToMove - } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt index c10b2c193b..a18f28450d 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MessageActionsBottomSheetDialog.kt @@ -160,7 +160,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Delete) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteMessages( messages = listOf(message), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -173,7 +173,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveMessages( messages = listOf(message), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -183,7 +183,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onReadUnread() { trackBottomSheetMessageActionsEvent(MatomoName.MarkAsSeen, message.isSeen) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleMessagesSeenStatus( messages = listOf(message), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! @@ -220,7 +220,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onFavorite() { trackBottomSheetMessageActionsEvent(MatomoName.Favorite, message.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleMessagesFavoriteStatus( messages = listOf(message), mailbox = mainViewModel.currentMailbox.value!! ) @@ -228,7 +228,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onSpam() { trackBottomSheetMessageActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleMessagesSpamStatus( messages = listOf(message), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt index aff85c5de8..d85b5eb40b 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MultiSelectBottomSheetDialog.kt @@ -140,13 +140,13 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.cancelSnooze.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.CancelSnooze, threadsCount, isFromBottomSheet = true) - lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads, mainViewModel.currentMailbox.value) } + lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads.toList(), mainViewModel.currentMailbox.value) } isMultiSelectOn = false } binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = threads, currentFolderId = mainViewModel.currentFolderId, mailbox = currentMailbox, @@ -175,7 +175,6 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { ) }, ) - isMultiSelectOn = false } @@ -203,7 +202,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = threadsUids, mailbox = currentMailbox, shouldFavorite = shouldFavorite @@ -244,7 +243,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { R.id.actionMove -> onMoveClicked(threadsCount, threadsUids, folderRole) R.id.actionReadUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = threadsUids, shouldRead = shouldRead, currentFolderId = currentFolderId, @@ -257,7 +256,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, threadsCount, isFromBottomSheet = true) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = threads.toList(), currentFolder = currentFolder, mailbox = currentMailbox @@ -270,7 +269,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = threads.toList(), currentFolder = currentFolder, mailbox = currentMailbox diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt index da55e45de6..f72383b4f8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ThreadActionsBottomSheetDialog.kt @@ -179,7 +179,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Delete) - actionsViewModel.deleteThreadsOrMessages( + actionsViewModel.deleteThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -192,7 +192,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) - actionsViewModel.archiveThreadsOrMessages( + actionsViewModel.archiveThreads( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! @@ -202,7 +202,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onReadUnread() { trackBottomSheetThreadActionsEvent(MatomoName.MarkAsSeen, value = thread.isSeen) - actionsViewModel.toggleThreadsOrMessagesSeenStatus( + actionsViewModel.toggleThreadsSeenStatus( threadsUids = listOf(navigationArgs.threadUid), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! @@ -248,7 +248,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onFavorite() { trackBottomSheetThreadActionsEvent(MatomoName.Favorite, thread.isFavorite) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + actionsViewModel.toggleThreadsFavoriteStatus( threadsUids = listOf(navigationArgs.threadUid), mailbox = mainViewModel.currentMailbox.value!! ) @@ -256,7 +256,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onSpam() { trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( + actionsViewModel.toggleThreadsSpamStatus( threads = setOf(thread), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt index cb695c3f12..594a3bda22 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt @@ -80,6 +80,7 @@ import com.infomaniak.mail.ui.main.SnackbarManager import com.infomaniak.mail.ui.newMessage.NewMessageActivity.DraftSaveConfiguration import com.infomaniak.mail.ui.newMessage.NewMessageEditorManager.EditorAction import com.infomaniak.mail.ui.newMessage.NewMessageRecipientFieldsManager.FieldType +import com.infomaniak.mail.useCases.MessagesActionsUseCase import com.infomaniak.mail.utils.AccountUtils import com.infomaniak.mail.utils.ContactUtils.arrangeMergedContacts import com.infomaniak.mail.utils.DraftInitManager @@ -87,7 +88,6 @@ import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.MessageBodyUtils import com.infomaniak.mail.utils.SentryDebug -import com.infomaniak.mail.utils.SharedUtils import com.infomaniak.mail.utils.SignatureUtils import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.coroutineContext @@ -150,7 +150,7 @@ class NewMessageViewModel @Inject constructor( private val contactGroupController: ContactGroupController, private val notificationManagerCompat: NotificationManagerCompat, private val draftInitManager: DraftInitManager, - private val sharedUtils: SharedUtils, + private val messagesActionsUseCase: MessagesActionsUseCase, private val signatureUtils: SignatureUtils, private val snackbarManager: SnackbarManager, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, @@ -508,7 +508,7 @@ class NewMessageViewModel @Inject constructor( val message = previousMessageUid?.let { MessageController.getMessage(it, realm) } ?: return if (message.isSeen) return - sharedUtils.markAsSeen( + messagesActionsUseCase.markAsSeen( mailbox = mailbox, threads = message.threads.filter { it.folderId == message.folderId }, message = message, diff --git a/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt new file mode 100644 index 0000000000..1f0426bd5a --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/useCases/MessagesActionsUseCase.kt @@ -0,0 +1,733 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.useCases + +import androidx.annotation.StringRes +import com.infomaniak.core.network.models.ApiResponse +import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError +import com.infomaniak.mail.R +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.api.ApiRepository +import com.infomaniak.mail.data.cache.RealmDatabase +import com.infomaniak.mail.data.cache.mailboxContent.FolderController +import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders +import com.infomaniak.mail.data.cache.mailboxContent.MessageController +import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks +import com.infomaniak.mail.data.cache.mailboxContent.ThreadController +import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController +import com.infomaniak.mail.data.models.Folder +import com.infomaniak.mail.data.models.Folder.FolderRole +import com.infomaniak.mail.data.models.MoveResult +import com.infomaniak.mail.data.models.isSnoozed +import com.infomaniak.mail.data.models.mailbox.Mailbox +import com.infomaniak.mail.data.models.mailbox.SendersRestrictions +import com.infomaniak.mail.data.models.message.Message +import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult +import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.ui.main.SnackbarManager.UndoData +import com.infomaniak.mail.utils.FeatureAvailability +import com.infomaniak.mail.utils.FolderRoleUtils +import com.infomaniak.mail.utils.SharedUtils +import com.infomaniak.mail.utils.extensions.atLeastOneFailed +import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded +import com.infomaniak.mail.utils.extensions.getFirstTranslatedError +import com.infomaniak.mail.utils.extensions.getFoldersIds +import com.infomaniak.mail.utils.extensions.getUids +import kotlinx.coroutines.coroutineScope +import java.util.Date +import javax.inject.Inject +import com.infomaniak.core.legacy.R as RCore + +class MessagesActionsUseCase @Inject constructor( + private val folderController: FolderController, + private val folderRoleUtils: FolderRoleUtils, + private val localSettings: LocalSettings, + private val mailboxContentRealm: RealmDatabase.MailboxContent, + private val mailboxController: MailboxController, + private val messageController: MessageController, + private val threadController: ThreadController, + private val sharedUtils: SharedUtils, +) { + + // Move Region + suspend fun moveMessagesTo( + destinationFolder: Folder, + currentFolderId: String?, + mailbox: Mailbox, + messages: List, + callbacks: RefreshCallbacks? = null, + ): MoveMessagesResult { + + val movedThreads = moveOutMessagesThreadsLocally(messages, destinationFolder) + val featureFlags = mailbox.featureFlags + + val apiResponses = moveMessages( + mailbox = mailbox, + messagesToMove = messages, + destinationFolder = destinationFolder, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), + ) + + if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), + destinationFolderId = destinationFolder.id, + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (apiResponses.atLeastOneFailed() && movedThreads.isNotEmpty()) { + threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) + } + + return MoveMessagesResult(movedThreads, messages, apiResponses, destinationFolder) + } + + suspend fun getMessagesToMove(threads: List, message: Message?) = when (message) { + null -> threads.flatMap { messageController.getMovableMessages(it) } + else -> listOf(message) + } + + suspend fun getMessagesFromThreadsToMove(threads: List): List { + return threads.flatMap { messageController.getMovableMessages(it) } + } + + fun getMessagesToMove(messages: List, currentFolderId: String?): List { + return messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } + } + + private suspend fun moveMessages( + mailbox: Mailbox, + messagesToMove: List, + destinationFolder: Folder, + alsoMoveReactionMessages: Boolean, + ): List> { + val apiResponses = ApiRepository.moveMessages( + mailboxUuid = mailbox.uuid, + messagesUids = messagesToMove.getUids(), + destinationId = destinationFolder.id, + alsoMoveReactionMessages = alsoMoveReactionMessages, + ) + + // TODO: Will unsync permantly the mailbox if one message in one of the batches did succeed but some other messages in the + // same batch or in other batches that are target by emoji reactions did not + if (alsoMoveReactionMessages && apiResponses.atLeastOneSucceeded()) deleteEmojiReactionMessagesLocally(messagesToMove) + + return apiResponses + } + + private suspend fun moveOutMessagesThreadsLocally(messages: List, destinationFolder: Folder): List { + val uidsToMove = mutableListOf() + mailboxContentRealm().run { + messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> + val realmThread = ThreadController.getThreadBlocking(thread.uid, realm = this) ?: return@forEach + val nbMessagesInCurrentFolder = realmThread.messages.count { it.folderId != destinationFolder.id } + if (nbMessagesInCurrentFolder == 0) uidsToMove.add(thread.uid) + } + } + + if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) + return uidsToMove + } + + suspend fun moveThreadsOrMessagesTo( + destinationFolderId: String, + threadsUids: List, + messagesUid: List? = null, + mailbox: Mailbox, + currentFolderId: String?, + ): MoveMessagesResult? { + if (currentFolderId == null) return null + + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return null + + var messagesToMove: List + if (messagesUid != null) { + messagesToMove = messagesUid.let { messageController.getMessages(it) } + } else { + val threads = threadController.getThreads(threadsUids).ifEmpty { return null } + messagesToMove = getMessagesFromThreadsToMove(threads) + } + + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) + } + // End Region + + // Spam Region + suspend fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null, + ): MoveMessagesResult? { + val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null + val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) + + val destinationFolderRole = if (folderRole == FolderRole.SPAM) { + FolderRole.INBOX + } else { + FolderRole.SPAM + } + val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return null + val unscheduleMessages = messageController.getUnscheduledMessages(messages) + + return moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, callbacks) + } + + suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } + } + + suspend fun activateSpamFilter(mailbox: Mailbox) { + ApiRepository.setSpamFilter( + mailboxHostingId = mailbox.hostingId, + mailboxName = mailbox.mailboxName, + activateSpamFilter = true, + ) + } + // End Region + + // Delete Region + suspend fun permanentlyDelete( + messagesToDelete: List, + currentFolder: Folder?, + mailbox: Mailbox, + onApiFinished: () -> Unit, + refreshCallbacks: RefreshCallbacks?, + ): DeleteResult? { + if (messagesToDelete.isEmpty()) { + return null + } + val uids = messagesToDelete.getUids() + val destinationFolder = folderController.getFolder(FolderRole.TRASH) ?: return null + val uidsToMove = moveOutMessagesThreadsLocally(messagesToDelete, destinationFolder) + + val apiResponses = ApiRepository.deleteMessages( + mailboxUuid = mailbox.uuid, + messagesUids = uids, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings), + ) + + onApiFinished() + + if (apiResponses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messagesToDelete.getFoldersIds(), + currentFolderId = currentFolder?.id, + callbacks = refreshCallbacks, + ) + } + + if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( + threadsUids = uidsToMove, + hasBeenMovedOut = false, + ) + + val undoDestinationId = messagesToDelete.first().folderId + val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) + + return DeleteResult( + apiResponses = apiResponses, + uidsToMove = uidsToMove, + undoResources = null, // since we are permanently deleting there isn't an undo action. + undoFoldersIds = undoFoldersIds, + undoDestinationId = undoDestinationId, + ) + } + + suspend fun getMessagesFromThreadToDelete(threads: List): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } + } + + suspend fun getMessagesToDelete(messages: List) = messageController.getMessagesAndDuplicates(messages) + // End Region + + // Seen Region + suspend fun toggleThreadSeenStatus( + threadsUids: List, + shouldRead: Boolean = true, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? = null, + ) { + val threads = threadsUids.let { threadController.getThreads(threadsUids) } + val isSeen = if (threads.count() == 1) threads.single().isSeen else !shouldRead + val messagesToToggleSeen = getMessagesFromThreadsToMarkAsUnseen(threads, mailbox) + + handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) + } + + suspend fun toggleMessagesSeenStatus( + messages: List, + shouldRead: Boolean = true, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks?, + ) { + val isSeen = if (messages.count() == 1) messages.single().isSeen else !shouldRead + val messagesToToggleSeen = getMessagesToMarkAsUnseen(messages) + + handleToggleSeenStatus(messagesToToggleSeen, isSeen, currentFolderId, mailbox, refreshCallbacks) + } + + private suspend fun handleToggleSeenStatus( + messages: List, + isSeen: Boolean, + currentFolderId: String?, + mailbox: Mailbox, + refreshCallbacks: RefreshCallbacks? = null, + ) { + if (isSeen) { + markAsUnseen(messages, mailbox, refreshCallbacks) + } else { + markMessagesAsSeen( + messages = messages, + currentFolderId = currentFolderId, + mailbox = mailbox, + callbacks = refreshCallbacks, + ) + } + } + + /** + * Mark a Message or some Threads as read + * @param mailbox The Mailbox where the Threads & Messages are located + * @param threads The Threads to mark as read + * @param message The Message to mark as read + * @param callbacks The callbacks for when the refresh of Threads begins/ends + * @param shouldRefreshThreads Sometimes, we don't want to refresh Threads after doing this action. For example, when replying + * to a Message. + */ + suspend fun markAsSeen( + mailbox: Mailbox, + threads: List, + message: Message? = null, + currentFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + shouldRefreshThreads: Boolean = true, + ) { + + val messages = when (message) { + null -> threads.flatMap { messageController.getUnseenMessages(it) } + else -> messageController.getMessageAndDuplicates(threads.first(), message) + } + + val threadsUids = threads.map { it.uid } + val messagesUids = messages.map { it.uid } + + updateSeenStatus(threadsUids, messagesUids, isSeen = true) + + val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) + + if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(threadsUids, messagesUids, isSeen = false) + } + + suspend fun markMessagesAsSeen( + mailbox: Mailbox, + messages: List, + currentFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + shouldRefreshThreads: Boolean = true, + ) { + + val messagesUids = messages.map { it.uid } + + updateSeenStatus(messagesUids, isSeen = true) + + val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) + + if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + currentFolderId = currentFolderId, + callbacks = callbacks, + ) + } + + if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(messagesUids, isSeen = false) + } + + private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { + mailboxContentRealm().write { + MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) + ThreadController.updateSeenStatus(threadsUids, isSeen, realm = this) + } + } + + suspend fun updateSeenStatus(messagesUids: List, isSeen: Boolean) { + mailboxContentRealm().write { + MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) + } + } + + private suspend fun getMessagesFromThreadsToMarkAsUnseen(threads: List, mailbox: Mailbox): List { + return threads.flatMap { thread -> + messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) + } + } + + private suspend fun getMessagesToMarkAsUnseen(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + private suspend fun markAsUnseen(messages: List, mailbox: Mailbox, callbacks: RefreshCallbacks?) { + val messagesUids = messages.map { it.uid } + + updateSeenStatus(messagesUids, isSeen = false) + + val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) + + if (apiResponses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + callbacks = callbacks, + ) + } else { + updateSeenStatus(messagesUids, isSeen = true) + } + } + // End Region + + // Favorites Region + suspend fun toggleThreadFavorite( + threadsUids: List, + shouldFavorite: Boolean = true, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null, + ) { + val threads = threadsUids.let { threadController.getThreads(threadsUids) } + val isFavorite = if (threads.count() == 1) threads.single().isFavorite else !shouldFavorite + val messages = if (isFavorite) { + getMessagesFromThreadToUnfavorite(threads) + } else { + getMessagesFromThreadToFavorite(threads, mailbox) + } + + toggleMessagesFavoriteStatus(messages, isFavorite, mailbox, callbacks) + } + + suspend fun toggleMessagesFavorite( + messages: List, + shouldFavorite: Boolean = true, + mailbox: Mailbox, + callbacks: RefreshCallbacks? = null, + ) { + val isFavorite = if (messages.count() == 1) messages.single().isFavorite else !shouldFavorite + + val messages = if (isFavorite) { + getMessagesToUnfavorite(messages) + } else { + getMessagesToFavorite(messages) + } + + toggleMessagesFavoriteStatus(messages, isFavorite, mailbox, callbacks) + } + + private suspend fun toggleMessagesFavoriteStatus( + messages: List, + isFavorite: Boolean, + mailbox: Mailbox, + callbacks: RefreshCallbacks?, + ) { + val uids = messages.getUids() + + updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) + + val apiResponses = if (isFavorite) { + ApiRepository.removeFromFavorites(mailbox.uuid, uids) + } else { + ApiRepository.addToFavorites(mailbox.uuid, uids) + } + + if (apiResponses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + callbacks = callbacks, + ) + } else { + updateFavoriteStatus(messagesUids = uids, isFavorite = isFavorite) + } + } + + private suspend fun getMessagesToFavorite(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + private suspend fun getMessagesToUnfavorite(messages: List): List { + return messageController.getMessagesAndDuplicates(messages) + } + + private suspend fun getMessagesFromThreadToFavorite(threads: List, mailbox: Mailbox): List { + return threads.flatMap { thread -> + messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) + } + } + + private suspend fun getMessagesFromThreadToUnfavorite(threads: List): List { + return threads.flatMap { messageController.getFavoriteMessages(it) } + } + + private suspend fun updateFavoriteStatus(messagesUids: List, isFavorite: Boolean) { + mailboxContentRealm().write { + MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) + } + } + // End Region + + // Phishing Region + suspend fun reportPhishing( + messages: List, + currentFolder: Folder?, + mailbox: Mailbox, + onReportSuccess: suspend () -> Unit, + ): ApiCallResult { + val messagesUids = messages.map { it.uid } + if (messagesUids.isEmpty()) return ApiCallResult.Error(RCore.string.anErrorHasOccurred) + + val response = ApiRepository.reportPhishing(mailbox.uuid, messagesUids) + + return if (response.isSuccess()) { + if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { + onReportSuccess() + } + ApiCallResult.Success(R.string.snackbarReportPhishingConfirmation) + } else { + ApiCallResult.Error(response.translateError()) + } + } + // End Region + + // Block Region + suspend fun blockUser( + folderId: String, + shortUid: Int, + mailbox: Mailbox, + ): ApiCallResult { + val response = ApiRepository.blockUser(mailbox.uuid, folderId, shortUid) + + return if (response.isSuccess()) { + ApiCallResult.Success(R.string.snackbarBlockUserConfirmation) + } else { + ApiCallResult.Error(response.translateError()) + } + } + + suspend fun unblockMail(email: String, mailbox: Mailbox): ApiCallResult? { + val response = ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName) + return if (response.isSuccess()) { + val restrictions = response.data ?: return ApiCallResult.Error(RCore.string.anErrorHasOccurred) + restrictions.apply { + blockedSenders.removeIf { it.email == email } + } + updateBlockedSenders(mailbox, restrictions) + ApiCallResult.Success(R.string.unblockButton) // We don't show a snackbar on success. It's just a confirmation + } else { + ApiCallResult.Error(response.translateError()) + } + } + + private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { + with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { + if (isSuccess()) { + mailboxController.updateMailbox(mailbox.objectId) { + it.sendersRestrictions = updatedSendersRestrictions + } + } + } + } + // End Region + + // Snooze Region + suspend fun snoozeThreads( + date: Date, + threadUids: List, + currentFolderId: String?, + mailbox: Mailbox, + ): SnoozeResult { + val threads = threadUids.mapNotNull { threadController.getThread(it) } + val messageUids = threads.mapNotNull { thread -> + thread.getDisplayedMessages(mailbox.featureFlags, localSettings) + .lastOrNull { it.folderId == currentFolderId }?.uid + } + + val responses = ApiRepository.snoozeMessages(mailbox.uuid, messageUids, date) + + return if (responses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders(mailbox, ImpactedFolders(mutableSetOf(FolderRole.SNOOZED))) + SnoozeResult.Success(threads.count(), date) + } else { + val errorRes = responses.getFirstTranslatedError() ?: RCore.string.anErrorHasOccurred + SnoozeResult.Error(errorRes) + } + } + + suspend fun rescheduleSnoozedThreads( + date: Date, + threadUids: List, + mailbox: Mailbox, + ): BatchSnoozeResult { + val snoozedThreadUuids = threadUids.mapNotNull { threadUid -> + val thread = threadController.getThread(threadUid) ?: return@mapNotNull null + thread.snoozeUuid.takeIf { thread.isSnoozed() } + } + + if (snoozedThreadUuids.isEmpty()) return BatchSnoozeResult.Error.Unknown + + val result = SharedUtils.rescheduleSnoozedThreads( + mailboxUuid = mailbox.uuid, + snoozeUuids = snoozedThreadUuids, + newDate = date, + impactedFolders = ImpactedFolders(mutableSetOf(FolderRole.SNOOZED)), + ) + + if (result is BatchSnoozeResult.Success) { + sharedUtils.refreshFolders(mailbox, result.impactedFolders) + } + + return result + } + + suspend fun unsnoozeThreads( + threads: Collection, + mailbox: Mailbox, + ): BatchSnoozeResult = coroutineScope { + val result = SharedUtils.unsnoozeThreadsWithoutRefresh( + mailbox = mailbox, + threads = threads, + scope = this + ) + + if (result is BatchSnoozeResult.Success) { + sharedUtils.refreshFolders(mailbox, result.impactedFolders) + } + + result + } + + sealed class SnoozeResult { + data class Success(val threadCount: Int, val date: Date) : SnoozeResult() + data class Error(@StringRes val messageRes: Int) : SnoozeResult() + } + // End Region + + // Undo Region + suspend fun undoAction(undoData: UndoData, mailbox: Mailbox): ApiCallResult { + val (resources, foldersIds, destinationFolderId) = undoData + if (resources == null) { + return ApiCallResult.Error(RCore.string.anErrorHasOccurred) + } + + val apiResponses = resources.map { ApiRepository.undoAction(it) } + + if (apiResponses.atLeastOneSucceeded()) { + sharedUtils.refreshFolders( + mailbox = mailbox, + messagesFoldersIds = foldersIds, + destinationFolderId = destinationFolderId, + ) + } + + val failedCall = apiResponses.firstOrNull { it.data != true } + + return if (failedCall == null) { + ApiCallResult.Success(R.string.snackbarMoveCancelled) + } else { + ApiCallResult.Error(failedCall.translateError()) + } + } + + fun getUndoData( + messagesMoved: List, + apiResponses: List>, + destinationFolder: Folder, + ): UndoData? { + val undoResources = apiResponses.mapNotNull { it.data?.undoResource } + val undoData = if (undoResources.isEmpty()) { + null + } else { + val undoDestinationId = destinationFolder.id + val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) + foldersIds += destinationFolder.id + UndoData( + resources = undoResources, + foldersIds = foldersIds, + destinationFolderId = undoDestinationId, + ) + } + return undoData + } + // End Region + + /** + * When deleting a message targeted by emoji reactions inside of a thread, the emoji reaction messages from another folder + * that were targeting this message will display for a brief moment until we refresh their folders. This is because those + * messages don't have a target message anymore and emoji reactions messages with no target in their thread need to be + * displayed. + * + * Deleting them from the database in the first place will prevent them from being shown and the messages will be deleted by + * the api at the same time anyway. + */ + suspend fun deleteEmojiReactionMessagesLocally(messagesToMove: List) { + for (messageToMove in messagesToMove) { + if (messageToMove.emojiReactions.isEmpty()) continue + + mailboxContentRealm().write { + messageToMove.emojiReactions.forEach { reaction -> + reaction.authors.forEach { author -> + MessageController.deleteMessageByUidBlocking(author.sourceMessageUid, this) + } + } + } + } + } + + sealed class ApiCallResult { + data class Success(val messageRes: Int) : ApiCallResult() + data class Error(val messageRes: Int) : ApiCallResult() + } + + data class MoveMessagesResult( + val movedThreads: List, + val messages: List, + val apiResponses: List>, + val destinationFolder: Folder, + ) + + data class DeleteResult( + val apiResponses: List>, + val uidsToMove: List, + val undoResources: List?, + val undoFoldersIds: ImpactedFolders, + val undoDestinationId: String, + ) +} diff --git a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt index 14f12067ee..e2eb08615a 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2025 Infomaniak Network SA + * Copyright (C) 2025-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by diff --git a/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt b/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt index ff8e201f94..7a4d02a678 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/NetworkManager.kt @@ -21,7 +21,6 @@ import com.infomaniak.core.network.NetworkAvailability import com.infomaniak.core.sentry.SentryLog import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.onEach @@ -31,8 +30,6 @@ import javax.inject.Singleton @Singleton class NetworkManager @Inject constructor() { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - /** * A StateFlow that emits the current network availability status. * It starts collecting immediately and keeps the latest value in memory. @@ -42,7 +39,7 @@ class NetworkManager @Inject constructor() { SentryLog.d("NetworkManager", if (available) "Online" else "Offline") } .stateIn( - scope = scope, + scope = CoroutineScope(Dispatchers.Default), started = SharingStarted.Eagerly, initialValue = true ) diff --git a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt index 476d895dd0..806f88e71b 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -24,11 +24,9 @@ import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders -import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCallbacks import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshMode -import com.infomaniak.mail.data.cache.mailboxContent.ThreadController import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.isSnoozed @@ -40,10 +38,7 @@ import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh -import com.infomaniak.mail.utils.extensions.atLeastOneSucceeded import com.infomaniak.mail.utils.extensions.getApiException -import com.infomaniak.mail.utils.extensions.getFoldersIds -import com.infomaniak.mail.utils.extensions.getUids import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.sentry.Sentry @@ -56,102 +51,8 @@ import javax.inject.Inject class SharedUtils @Inject constructor( private val mailboxContentRealm: RealmDatabase.MailboxContent, private val refreshController: RefreshController, - private val messageController: MessageController, private val mailboxController: MailboxController, ) { - /** - * Mark a Message or some Threads as read - * @param mailbox The Mailbox where the Threads & Messages are located - * @param threads The Threads to mark as read - * @param message The Message to mark as read - * @param callbacks The callbacks for when the refresh of Threads begins/ends - * @param shouldRefreshThreads Sometimes, we don't want to refresh Threads after doing this action. For example, when replying - * to a Message. - */ - suspend fun markAsSeen( - mailbox: Mailbox, - threads: List, - message: Message? = null, - currentFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - shouldRefreshThreads: Boolean = true, - ) { - - val messages = when (message) { - null -> threads.flatMap { messageController.getUnseenMessages(it) } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } - - val threadsUids = threads.map { it.uid } - val messagesUids = messages.map { it.uid } - - updateSeenStatus(threadsUids, messagesUids, isSeen = true) - - val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) - - if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { - refreshFolders( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - currentFolderId = currentFolderId, - callbacks = callbacks, - ) - } - - if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(threadsUids, messagesUids, isSeen = false) - } - - suspend fun markMessagesAsSeen( - mailbox: Mailbox, - messages: List, - currentFolderId: String? = null, - callbacks: RefreshCallbacks? = null, - shouldRefreshThreads: Boolean = true, - ) { - - val messagesUids = messages.map { it.uid } - - updateSeenStatus(messagesUids, isSeen = true) - - val apiResponses = ApiRepository.markMessagesAsSeen(mailbox.uuid, messages.getUids()) - - if (apiResponses.atLeastOneSucceeded() && shouldRefreshThreads) { - refreshFolders( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - currentFolderId = currentFolderId, - callbacks = callbacks, - ) - } - - if (!apiResponses.atLeastOneSucceeded()) updateSeenStatus(messagesUids, isSeen = false) - } - - - private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { - mailboxContentRealm().write { - MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) - ThreadController.updateSeenStatus(threadsUids, isSeen, realm = this) - } - } - - suspend fun updateSeenStatus(messagesUids: List, isSeen: Boolean) { - mailboxContentRealm().write { - MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) - } - } - - suspend fun getMessagesToMove(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getMovableMessages(it) } - else -> listOf(message) - } - - suspend fun getMessagesToMove(threads: List?, messages: List?, currentFolderId: String?) = when { - messages != null -> messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } - threads != null -> threads.flatMap { messageController.getMovableMessages(it) } - else -> emptyList() //this should never happen, we have to send a list of threads or messages. - } - suspend fun refreshFolders( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders,