From bc277e3d0ff8118125d36fc6b88bf0e27dbac976 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 27 Jan 2026 13:28:15 +0100 Subject: [PATCH 01/13] refactor: Change spam and move actions to use messages and not threads --- .../cache/mailboxContent/MessageController.kt | 6 +- .../com/infomaniak/mail/ui/MainViewModel.kt | 4 +- .../main/folder/PerformSwipeActionManager.kt | 6 +- .../mail/ui/main/folder/TwoPaneFragment.kt | 2 + .../mail/ui/main/thread/ThreadFragment.kt | 8 +- .../main/thread/actions/ActionsViewModel.kt | 306 ++++++++++++++++++ .../actions/MailActionsBottomSheetDialog.kt | 2 +- .../MessageActionsBottomSheetDialog.kt | 8 +- .../actions/MultiSelectBottomSheetDialog.kt | 8 +- .../actions/ThreadActionsBottomSheetDialog.kt | 14 +- 10 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt index 97bf55c749..9c78c64830 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt @@ -113,10 +113,14 @@ class MessageController @Inject constructor( return getMessagesFromThread(thread, "$byFolderId AND $isNotScheduledMessage", includeDuplicates = false) } - suspend fun getUnscheduledMessages(thread: Thread, includeDuplicates: Boolean): List { + suspend fun getUnscheduledMessagesFromThread(thread: Thread, includeDuplicates: Boolean): List { return getMessagesFromThread(thread, isNotScheduledMessage, includeDuplicates) } + fun getUnscheduledMessages(messages: List): List { + return messages.filter { message -> !message.isScheduledMessage } + } + private suspend fun getMessagesFromThread(thread: Thread, query: String, includeDuplicates: Boolean): List { val messages = thread.messages.query(query).findSuspend() if (includeDuplicates) { 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 8e45606931..a19bad4c88 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -780,7 +780,7 @@ class MainViewModel @Inject constructor( } private suspend fun getMessagesToDelete(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getUnscheduledMessages(it, includeDuplicates = true) } + null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } else -> messageController.getMessageAndDuplicates(threads.first(), message) } @@ -1202,7 +1202,7 @@ class MainViewModel @Inject constructor( } private suspend fun getMessagesToSpamOrHam(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getUnscheduledMessages(it, includeDuplicates = false) } + null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } else -> listOf(message) } //endregion 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 f7c2ff6952..c476347af5 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 @@ -149,7 +149,11 @@ object PerformSwipeActionManager { mainViewModel.currentFilter.value != ThreadFilter.UNSEEN } SwipeAction.SPAM -> { - mainViewModel.toggleThreadSpamStatus(listOf(thread.uid)) + actionsViewModel.toggleMessagesSpamStatus( + thread.messages, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) false } SwipeAction.SNOOZE -> { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt index fb616f7202..be6e9bebc6 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt @@ -44,6 +44,7 @@ import com.infomaniak.mail.ui.bottomSheetDialogs.SnoozeBottomSheetDialogArgs import com.infomaniak.mail.ui.main.search.SearchFragment import com.infomaniak.mail.ui.main.thread.ThreadFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.ui.main.thread.actions.DownloadMessagesProgressDialog import com.infomaniak.mail.utils.LocalStorageUtils.clearEmlCacheDir import com.infomaniak.mail.utils.extensions.AttachmentExt @@ -57,6 +58,7 @@ import javax.inject.Inject abstract class TwoPaneFragment : Fragment() { val mainViewModel: MainViewModel by activityViewModels() + val actionsViewModel: ActionsViewModel by activityViewModels() private val twoPaneViewModel: TwoPaneViewModel by activityViewModels() // TODO: When we'll update DragDropSwipeRecyclerViewLib, we'll need to make the adapter nullable. 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 6e46e678a2..135c35d68c 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 @@ -93,6 +93,7 @@ import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ContextMenuType import com.infomaniak.mail.ui.main.thread.ThreadAdapter.ThreadAdapterCallbacks import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType import com.infomaniak.mail.ui.main.thread.ThreadViewModel.ThreadHeaderVisibility +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.ui.main.thread.actions.AttachmentActionsBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.actions.ConfirmationToBlockUserDialog import com.infomaniak.mail.ui.main.thread.actions.JunkMessagesViewModel @@ -196,6 +197,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val twoPaneViewModel: TwoPaneViewModel by activityViewModels() private val threadViewModel: ThreadViewModel by viewModels() + private val actionsViewModel: ActionsViewModel by viewModels() private val twoPaneFragment inline get() = parentFragment as TwoPaneFragment private val threadAdapter inline get() = binding.messagesList.adapter as ThreadAdapter @@ -369,7 +371,11 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { }, onUnsubscribeClicked = threadViewModel::unsubscribeMessage, moveMessageToSpam = { messageUid -> - twoPaneViewModel.currentThreadUid.value?.let { mainViewModel.moveToSpamFolder(it, messageUid) } + actionsViewModel.moveToSpamFolder( + listOf(messageUid), + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) }, activateSpamFilter = mainViewModel::activateSpamFilter, unblockMail = mainViewModel::unblockMail, 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 new file mode 100644 index 0000000000..bb7af0b9db --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -0,0 +1,306 @@ +/* + * 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.ui.main.thread.actions + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +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.mailbox.Mailbox +import com.infomaniak.mail.data.models.message.Message +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.utils.FolderRoleUtils +import com.infomaniak.mail.utils.SharedUtils +import com.infomaniak.mail.utils.coroutineContext +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 dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +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 snackbarManager: SnackbarManager, + private val threadController: ThreadController, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : AndroidViewModel(application) { + + private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) + val isDownloadingChanges: MutableLiveData = MutableLiveData(false) + private val _currentMailboxObjectId = MutableStateFlow(null) + val currentMailbox = _currentMailboxObjectId.mapLatest { id -> + id?.let { mailboxController.getMailbox(it) } + }.asLiveData(ioCoroutineContext) + + private val currentMailboxLive = _currentMailboxObjectId.filterNotNull().flatMapLatest { objectId -> + mailboxController.getMailboxAsync(objectId).mapNotNull { it.obj } + }.asLiveData(ioCoroutineContext) + + val featureFlagsLive = currentMailboxLive.map { it.featureFlags } + + //region Spam + + fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) = + viewModelScope.launch(ioCoroutineContext) { + val messages = messageController.getMessages(messagesUid) + toggleMessagesSpamStatus(messages, currentFolderId, mailbox) + } + + fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true + ) { + toggleThreadsOrMessageSpamStatus(messages, currentFolderId, mailbox, displaySnackbar) + } + + private fun toggleThreadsOrMessageSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true, + ) = viewModelScope.launch(ioCoroutineContext) { + // we check only one message folder role because: + // if we are in a specific folder all messages will have the same folder role, + // if we are in search we don't show the messages that are already in SPAM so none of them will be in the SPAM folder + + val firstMessage = messages.first() + + val destinationFolderRole = if (folderRoleUtils.getActionFolderRole(firstMessage) == FolderRole.SPAM) { + FolderRole.INBOX + } else { + FolderRole.SPAM + } + val destinationFolder = folderController.getFolder(destinationFolderRole)!! + + val messages = messageController.getUnscheduledMessages(messages) + + moveMessagesTo(destinationFolder, currentFolderId, mailbox, messages, displaySnackbar) + } + + //endregion + + //region Move + fun moveThreadsOrMessageTo( + destinationFolderId: String, + threadsUids: List, + messageUid: String? = null, + currentFolderId: String, + mailbox: Mailbox, + ) = viewModelScope.launch(ioCoroutineContext) { + val destinationFolder = folderController.getFolder(destinationFolderId)!! + val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } + val message = messageUid?.let { messageController.getMessage(it)!! } + val messagesToMove = sharedUtils.getMessagesToMove(threads, message) + + moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) + } + + private suspend fun moveMessagesTo( + destinationFolder: Folder, + currentFolderId: String?, + mailbox: Mailbox, + messages: List, + shouldDisplaySnackbar: Boolean = true, + ) { + + val movedThreads = moveOutThreadsLocally(messages, destinationFolder) + + val apiResponses = moveMessages( + mailbox = mailbox, + messagesToMove = messages, + destinationFolder = destinationFolder, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlagsLive.value, 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) + } + + if (shouldDisplaySnackbar) showMoveSnackbar(movedThreads, messages, 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( + threadsMoved: List, + messages: List, + apiResponses: List>, + destinationFolder: Folder, + ) { + + val destination = destinationFolder.getLocalizedName(appContext) + + val snackbarTitle = when { + apiResponses.allFailed() -> appContext.getString(apiResponses.first().translateError()) + threadsMoved.count() > 0 -> appContext.resources.getQuantityString( + R.plurals.snackbarThreadMoved, + threadsMoved.count(), + destination + ) + //TODO: A MESSAGES MOVED QUANTITY STRING + 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 = messages.getFoldersIds(exception = undoDestinationId) + foldersIds += destinationFolder.id + UndoData( + resources = apiResponses.mapNotNull { it.data?.undoResource }, + foldersIds = foldersIds, + destinationFolderId = undoDestinationId, + ) + } + + snackbarManager.postValue(snackbarTitle, undoData) + } + //endregion + + 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 + } + + private fun refreshFoldersAsync( + mailbox: Mailbox, + messagesFoldersIds: ImpactedFolders, + currentFolderId: String, + destinationFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + ) = viewModelScope.launch(ioCoroutineContext) { + sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) + } + + private fun onDownloadStart() { + isDownloadingChanges.postValue(true) + } + + private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { + threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) + isDownloadingChanges.postValue(false) + } + +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index 37be708118..f34239c020 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -93,7 +93,7 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { modifySnooze.setOnClickListener { onClickListener.onModifySnooze() } cancelSnooze.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onCancelSnooze() } favorite.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onFavorite() } - spam.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onSpam() } + spam.setOnClickListener { onClickListener.onSpam() } phishing.setOnClickListener { onClickListener.onPhishing() } blockSender.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onBlockSender() } print.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onPrint() } 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 af273d5d41..3bbcc4f45b 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 @@ -21,6 +21,7 @@ import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -61,6 +62,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: MessageActionsBottomSheetDialogArgs by navArgs() + private val actionsViewModel: ActionsViewModel by viewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val currentClassName: String by lazy { MessageActionsBottomSheetDialog::class.java.name } @@ -212,7 +214,11 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onSpam() { trackBottomSheetMessageActionsEvent(MatomoName.Spam, value = isFromSpam) - mainViewModel.toggleMessageSpamStatus(threadUid, message) + actionsViewModel.toggleMessagesSpamStatus( + listOf(message), + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) } override fun onPhishing() { 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 dc8d29ca00..b69319ea24 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 @@ -73,6 +73,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetMultiSelectBinding by safeBinding() override val mainViewModel: MainViewModel by activityViewModels() + private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() @Inject @@ -138,7 +139,12 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - toggleThreadSpamStatus(threadsUids) + val threadMessages = threads.flatMap { it.messages } + actionsViewModel.toggleMessagesSpamStatus( + threadMessages, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) isMultiSelectOn = false } 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 f0de8a2c66..b5f85f065b 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 @@ -68,6 +68,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: ThreadActionsBottomSheetDialogArgs by navArgs() private val threadActionsViewModel: ThreadActionsViewModel by viewModels() + private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val currentClassName: String by lazy { ThreadActionsBottomSheetDialog::class.java.name } @@ -239,13 +240,12 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } override fun onSpam() { - if (isFromSpam) { - trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = true) - mainViewModel.toggleThreadSpamStatus(listOf(thread.uid)) - } else { - trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = false) - mainViewModel.toggleThreadSpamStatus(listOf(thread.uid)) - } + trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = isFromSpam) + actionsViewModel.toggleMessagesSpamStatus( + thread.messages, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) } override fun onPhishing() { From e4d6299ff3a974ae3a62b79d40434df7bae04fdc Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 30 Jan 2026 08:13:57 +0100 Subject: [PATCH 02/13] feat: Refactor of actions to use messages instead of threads --- .../cache/mailboxContent/MessageController.kt | 6 + .../com/infomaniak/mail/ui/MainActivity.kt | 4 +- .../main/folder/PerformSwipeActionManager.kt | 31 +- .../mail/ui/main/folder/ThreadListFragment.kt | 11 +- .../main/folder/ThreadListMultiSelection.kt | 29 +- .../main/folderPicker/FolderPickerFragment.kt | 8 +- .../mail/ui/main/thread/ThreadFragment.kt | 61 +- .../main/thread/actions/ActionsViewModel.kt | 695 +++++++++++++++++- .../MessageActionsBottomSheetDialog.kt | 58 +- .../actions/MultiSelectBottomSheetDialog.kt | 61 +- .../actions/ThreadActionsBottomSheetDialog.kt | 50 +- .../infomaniak/mail/utils/FolderRoleUtils.kt | 23 + .../com/infomaniak/mail/utils/SharedUtils.kt | 39 + 13 files changed, 969 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt index 9c78c64830..c6a9911183 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt @@ -139,6 +139,12 @@ class MessageController @Inject constructor( messagesAsync.await() + duplicatesAsync.await() } + suspend fun getMessagesAndDuplicates(messages: List): List { + return messages.flatMap { message -> + getMessageAndDuplicates(message.threads.first(), message) + } + } + suspend fun getMessageAndDuplicates(thread: Thread, message: Message): List { return listOf(message) + thread.duplicates.query("${Message::messageId.name} == $0", message.messageId).findSuspend() } diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt index 159c244d08..d3df913e31 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt @@ -75,6 +75,7 @@ import com.infomaniak.mail.ui.main.folder.TwoPaneFragment import com.infomaniak.mail.ui.main.menuDrawer.MenuDrawerFragment import com.infomaniak.mail.ui.main.onboarding.PermissionsOnboardingPagerFragment import com.infomaniak.mail.ui.main.search.SearchFragmentArgs +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.ui.newMessage.NewMessageActivity import com.infomaniak.mail.ui.sync.SyncAutoConfigActivity import com.infomaniak.mail.ui.sync.discovery.SyncDiscoveryManager @@ -113,6 +114,7 @@ class MainActivity : BaseActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } private val mainViewModel: MainViewModel by viewModels() + private val actionsViewModel: ActionsViewModel by viewModels() private val navigationArgs: MainActivityArgs? by lazy { intent?.extras?.let(MainActivityArgs::fromBundle) } @@ -488,7 +490,7 @@ class MainActivity : BaseActivity() { getAnchor = ::getAnchor, onUndoData = { trackEvent(MatomoMail.MatomoCategory.Snackbar, MatomoName.Undo) - mainViewModel.undoAction(it) + actionsViewModel.undoAction(it, mainViewModel.currentMailbox.value!!) }, ) } 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 c476347af5..c282479a71 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 @@ -97,7 +97,11 @@ object PerformSwipeActionManager { } }, ) { - mainViewModel.archiveThread(thread.uid) + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } SwipeAction.DELETE -> { @@ -114,12 +118,19 @@ object PerformSwipeActionManager { }, callback = { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - mainViewModel.deleteThread(thread.uid) + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) }, ) } SwipeAction.FAVORITE -> { - mainViewModel.toggleThreadFavoriteStatus(thread.uid) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = listOf(thread.uid), + mailbox = mainViewModel.currentMailbox.value!! + ) true } SwipeAction.MOVE -> { @@ -145,14 +156,18 @@ object PerformSwipeActionManager { true } SwipeAction.READ_UNREAD -> { - mainViewModel.toggleThreadSeenStatus(thread.uid) + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + threadsUids = listOf(thread.uid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) mainViewModel.currentFilter.value != ThreadFilter.UNSEEN } SwipeAction.SPAM -> { - actionsViewModel.toggleMessagesSpamStatus( - thread.messages, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = setOf(thread), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) false } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt index 121fc6ded3..3753d96580 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/folder/ThreadListFragment.kt @@ -41,6 +41,7 @@ import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.Adapter.StateRestorationPolicy import com.infomaniak.core.common.extensions.goToAppStore +import com.infomaniak.core.common.utils.isToday import com.infomaniak.core.inappupdate.updatemanagers.InAppUpdateManager import com.infomaniak.core.ksuite.data.KSuite import com.infomaniak.core.legacy.utils.SnackbarUtils.showSnackbar @@ -50,7 +51,6 @@ import com.infomaniak.core.legacy.utils.safeNavigate import com.infomaniak.core.legacy.utils.setMargins import com.infomaniak.core.legacy.utils.setPaddingRelative import com.infomaniak.core.sentry.SentryLog -import com.infomaniak.core.common.utils.isToday import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation import com.infomaniak.dragdropswiperecyclerview.DragDropSwipeRecyclerView.ListOrientation.DirectionFlag import com.infomaniak.dragdropswiperecyclerview.listener.OnItemSwipeListener @@ -176,6 +176,7 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { threadListMultiSelection.initMultiSelection( mainViewModel = mainViewModel, + actionsViewModel = actionsViewModel, threadListFragment = this, unlockSwipeActionsIfSet = ::unlockSwipeActionsIfSet, localSettings = localSettings, @@ -704,7 +705,13 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver { trackEmojiReactionsEvent(MatomoName.AddReactionFromEmojiPicker) viewLifecycleOwner.lifecycleScope.launch { threadListViewModel.getEmojiReactionsFor(messageUid)?.let { reactions -> - mainViewModel.trySendEmojiReply(emoji, messageUid, reactions) + actionsViewModel.trySendEmojiReply( + emoji = emoji, + messageUid = messageUid, + reactions = reactions, + hasNetwork = mainViewModel.hasNetwork, + mailbox = mainViewModel.currentMailbox.value!! + ) } } } 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 357e30fc50..11a36a3663 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 @@ -33,6 +33,7 @@ import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.ui.MainActivity import com.infomaniak.mail.ui.MainViewModel +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup @@ -41,6 +42,7 @@ import kotlinx.coroutines.launch class ThreadListMultiSelection { lateinit var mainViewModel: MainViewModel + lateinit var actionsViewModel: ActionsViewModel private lateinit var threadListFragment: ThreadListFragment lateinit var unlockSwipeActionsIfSet: () -> Unit lateinit var localSettings: LocalSettings @@ -50,11 +52,13 @@ class ThreadListMultiSelection { fun initMultiSelection( mainViewModel: MainViewModel, + actionsViewModel: ActionsViewModel, threadListFragment: ThreadListFragment, unlockSwipeActionsIfSet: () -> Unit, localSettings: LocalSettings, ) { this.mainViewModel = mainViewModel + this.actionsViewModel = actionsViewModel this.threadListFragment = threadListFragment this.unlockSwipeActionsIfSet = unlockSwipeActionsIfSet this.localSettings = localSettings @@ -72,7 +76,12 @@ class ThreadListMultiSelection { when (menuId) { R.id.quickActionUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedThreadsCount) - toggleThreadsSeenStatus(selectedThreadsUids, shouldMultiselectRead) + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + selectedThreadsUids, + shouldRead = shouldMultiselectRead, + currentFolderId = currentFolderId, + mailbox = currentMailbox.value!! + ) isMultiSelectOn = false } R.id.quickActionArchive -> threadListFragment.lifecycleScope.launch { @@ -81,13 +90,21 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, selectedThreadsCount) - archiveThreads(selectedThreadsUids) + actionsViewModel.archiveThreadsOrMessages( + threads = selectedThreads.toList(), + currentFolder = currentFolder.value, + mailbox = currentMailbox.value!! + ) isMultiSelectOn = false } } R.id.quickActionFavorite -> { trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) - toggleThreadsFavoriteStatus(selectedThreadsUids, shouldMultiselectFavorite) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + selectedThreadsUids, + mailbox = currentMailbox.value!!, + shouldFavorite = shouldMultiselectFavorite + ) isMultiSelectOn = false } R.id.quickActionDelete -> threadListFragment.lifecycleScope.launch { @@ -96,7 +113,11 @@ class ThreadListMultiSelection { count = selectedThreadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, selectedThreadsCount) - deleteThreads(selectedThreadsUids) + actionsViewModel.deleteThreadsOrMessages( + threads = selectedThreads.toList(), + currentFolder = currentFolder.value, + mailbox = currentMailbox.value!! + ) isMultiSelectOn = 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 e162505854..6fc607e67e 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 @@ -41,6 +41,7 @@ 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.search.SearchViewModel +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.extensions.applySideAndBottomSystemInsets import com.infomaniak.mail.utils.extensions.applyStatusBarInsets @@ -61,6 +62,7 @@ class FolderPickerFragment : Fragment() { private val folderPickerViewModel: FolderPickerViewModel by viewModels() private val searchViewModel: SearchViewModel by activityViewModels() private val mainViewModel: MainViewModel by activityViewModels() + private val actionsViewModel: ActionsViewModel by activityViewModels() @Inject lateinit var createFolderDialog: CreateFolderDialog @@ -151,10 +153,12 @@ class FolderPickerFragment : Fragment() { private fun onFolderSelected(folder: Folder?): Unit = with(navigationArgs) { when (action) { FolderPickerAction.MOVE -> folder?.id?.let { - mainViewModel.moveThreadsOrMessageTo( + actionsViewModel.moveThreadsOrMessagesTo( it, threadsUids.toList(), - messageUid + messageUid?.let { listOf(messageUid) }, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! ) } FolderPickerAction.SEARCH -> { 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 135c35d68c..ed1cdd9dd6 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 @@ -259,7 +259,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { junkMessagesViewModel.messageOfUserToBlock.observe(viewLifecycleOwner) { messageOfUserToBlock -> setPositiveButtonCallback { message -> trackBlockUserAction(MatomoName.ConfirmSelectedUser) - mainViewModel.blockUser(message.folderId, message.shortUid) + actionsViewModel.blockUser(message.folderId, message.shortUid, mainViewModel.currentMailbox.value!!) } show(messageOfUserToBlock) } @@ -377,7 +377,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { mainViewModel.currentMailbox.value!! ) }, - activateSpamFilter = mainViewModel::activateSpamFilter, + activateSpamFilter = { actionsViewModel.activateSpamFilter(mainViewModel.currentMailbox.value!!) }, unblockMail = mainViewModel::unblockMail, replyToCalendarEvent = { attendanceState, message -> replyToCalendarEvent( @@ -426,9 +426,15 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { trackEmojiReactionsEvent(MatomoName.AddReactionFromChip) } - mainViewModel.trySendEmojiReply(emoji, messageUid, reactions, onAllowed = { - threadViewModel.fakeEmojiReply(emoji, messageUid) - }) + actionsViewModel.trySendEmojiReply( + emoji = emoji, + messageUid = messageUid, + reactions = reactions, + hasNetwork = mainViewModel.hasNetwork, + mailbox = mainViewModel.currentMailbox.value!!, + onAllowed = { + threadViewModel.fakeEmojiReply(emoji, messageUid) + }) }, showEmojiDetails = { messageUid, emoji -> trackEmojiReactionsEvent(MatomoName.ShowReactionsBottomSheet) @@ -671,9 +677,15 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { getBackNavigationResult(EmojiPickerObserverTarget.Thread.name) { (emoji, messageUid) -> trackEmojiReactionsEvent(MatomoName.AddReactionFromEmojiPicker) val reactions = threadViewModel.getLocalEmojiReactionsFor(messageUid) ?: return@getBackNavigationResult - mainViewModel.trySendEmojiReply(emoji, messageUid, reactions, onAllowed = { - threadViewModel.fakeEmojiReply(emoji, messageUid) - }) + actionsViewModel.trySendEmojiReply( + emoji = emoji, + messageUid = messageUid, + reactions = reactions, + hasNetwork = mainViewModel.hasNetwork, + mailbox = mainViewModel.currentMailbox.value!!, + onAllowed = { + threadViewModel.fakeEmojiReply(emoji, messageUid) + }) } } @@ -780,7 +792,12 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private fun snoozeThreads(timestamp: Long, threadUids: List) { lifecycleScope.launch { - val isSuccess = mainViewModel.snoozeThreads(Date(timestamp), threadUids) + val isSuccess = actionsViewModel.snoozeThreads( + Date(timestamp), + threadUids, + mainViewModel.currentFolderId, + mainViewModel.currentMailbox.value!! + ) if (isSuccess) twoPaneViewModel.closeThread() } } @@ -789,7 +806,11 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { lifecycleScope.launch { binding.snoozeAlert.showAction1Progress() - val result = mainViewModel.rescheduleSnoozedThreads(Date(timestamp), threadUids) + val result = actionsViewModel.rescheduleSnoozedThreads( + Date(timestamp), + threadUids, + mainViewModel.currentMailbox.value!! + ) binding.snoozeAlert.hideAction1Progress(R.string.buttonModify) if (result is BatchSnoozeResult.Success) twoPaneViewModel.closeThread() @@ -804,7 +825,10 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private fun initUi(threadUid: String, folderRole: FolderRole?) = with(binding) { iconFavorite.setOnClickListener { trackThreadActionsEvent(MatomoName.Favorite, threadViewModel.threadLive.value!!.isFavorite) - mainViewModel.toggleThreadFavoriteStatus(threadUid) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = listOf(threadUid), + mailbox = mainViewModel.currentMailbox.value!! + ) } val isFromArchive = folderRole == FolderRole.ARCHIVE @@ -828,13 +852,22 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { R.id.quickActionArchive -> { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Archive, isFromArchive) - mainViewModel.archiveThread(threadUid) + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(threadViewModel.threadLive.value!!), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } R.id.quickActionDelete -> { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Delete) - mainViewModel.deleteThread(threadUid) + // TODO: CHECK NULL + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(threadViewModel.threadLive.value!!), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } R.id.quickActionMenu -> { @@ -1002,7 +1035,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { lifecycleScope.launch { snoozeAlert.showAction2Progress() - val result = mainViewModel.unsnoozeThreads(listOf(thread)) + val result = actionsViewModel.unsnoozeThreads(listOf(thread), mainViewModel.currentMailbox.value) snoozeAlert.hideAction2Progress(R.string.buttonCancelReminder) if (result is BatchSnoozeResult.Success) twoPaneViewModel.closeThread() 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 bb7af0b9db..b1dd411235 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 @@ -18,61 +18,76 @@ package com.infomaniak.mail.ui.main.thread.actions import android.app.Application +import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.asLiveData -import androidx.lifecycle.map import androidx.lifecycle.viewModelScope +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.emojicomponents.data.Reaction 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.DraftController 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.draft.Draft +import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox 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.AccountUtils +import com.infomaniak.mail.utils.DraftInitManager +import com.infomaniak.mail.utils.EmojiReactionUtils.hasAvailableReactionSlot +import com.infomaniak.mail.utils.ErrorCode import com.infomaniak.mail.utils.FeatureAvailability 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 +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 com.infomaniak.mail.workers.DraftsActionsWorker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.invoke import kotlinx.coroutines.launch +import java.util.Date import javax.inject.Inject +import com.infomaniak.core.legacy.R as RCore @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ActionsViewModel @Inject constructor( application: Application, + private val draftController: DraftController, + private val draftInitManager: DraftInitManager, + private val draftsActionsWorkerScheduler: DraftsActionsWorker.Scheduler, 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 snackbarManager: SnackbarManager, @@ -82,47 +97,44 @@ class ActionsViewModel @Inject constructor( private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) val isDownloadingChanges: MutableLiveData = MutableLiveData(false) - private val _currentMailboxObjectId = MutableStateFlow(null) - val currentMailbox = _currentMailboxObjectId.mapLatest { id -> - id?.let { mailboxController.getMailbox(it) } - }.asLiveData(ioCoroutineContext) - - private val currentMailboxLive = _currentMailboxObjectId.filterNotNull().flatMapLatest { objectId -> - mailboxController.getMailboxAsync(objectId).mapNotNull { it.obj } - }.asLiveData(ioCoroutineContext) - - val featureFlagsLive = currentMailboxLive.map { it.featureFlags } + val activityDialogLoaderResetTrigger = SingleLiveEvent() + val spamTrigger = SingleLiveEvent() + val reportPhishingTrigger = SingleLiveEvent() //region Spam - fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUid) toggleMessagesSpamStatus(messages, currentFolderId, mailbox) } - fun toggleMessagesSpamStatus( - messages: List, + fun toggleThreadsOrMessagesSpamStatus( + messages: List? = null, + threads: Set? = null, currentFolderId: String?, mailbox: Mailbox, displaySnackbar: Boolean = true - ) { - toggleThreadsOrMessageSpamStatus(messages, currentFolderId, mailbox, displaySnackbar) + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMarkAsSpam = when { + threads != null -> getMessagesFromThreadToSpamOrHam(threads) + messages != null -> messageController.getUnscheduledMessages(messages) + else -> emptyList() + } + + toggleMessagesSpamStatus(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) } - private fun toggleThreadsOrMessageSpamStatus( + private fun toggleMessagesSpamStatus( messages: List, currentFolderId: String?, mailbox: Mailbox, displaySnackbar: Boolean = true, ) = viewModelScope.launch(ioCoroutineContext) { - // we check only one message folder role because: - // if we are in a specific folder all messages will have the same folder role, - // if we are in search we don't show the messages that are already in SPAM so none of them will be in the SPAM folder - val firstMessage = messages.first() + val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null + val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) - val destinationFolderRole = if (folderRoleUtils.getActionFolderRole(firstMessage) == FolderRole.SPAM) { + val destinationFolderRole = if (folderRole == FolderRole.SPAM) { FolderRole.INBOX } else { FolderRole.SPAM @@ -132,22 +144,35 @@ class ActionsViewModel @Inject constructor( val messages = messageController.getUnscheduledMessages(messages) moveMessagesTo(destinationFolder, currentFolderId, mailbox, messages, displaySnackbar) + spamTrigger.postValue(Unit) + } + + fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + ApiRepository.setSpamFilter( + mailboxHostingId = mailbox.hostingId, + mailboxName = mailbox.mailboxName, + activateSpamFilter = true, + ) + } + + private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } } //endregion //region Move - fun moveThreadsOrMessageTo( + fun moveThreadsOrMessagesTo( destinationFolderId: String, - threadsUids: List, - messageUid: String? = null, - currentFolderId: String, + threadsUids: List? = null, + messagesUid: List? = null, + currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { val destinationFolder = folderController.getFolder(destinationFolderId)!! - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - val message = messageUid?.let { messageController.getMessage(it)!! } - val messagesToMove = sharedUtils.getMessagesToMove(threads, message) + val threads: List? = threadsUids?.let { threadController.getThreads(threadsUids).toList() } + val messages = messagesUid?.let { messageController.getMessages(it) } + val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) } @@ -161,12 +186,13 @@ class ActionsViewModel @Inject constructor( ) { val movedThreads = moveOutThreadsLocally(messages, destinationFolder) + val featureFlags = mailbox.featureFlags val apiResponses = moveMessages( mailbox = mailbox, messagesToMove = messages, destinationFolder = destinationFolder, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlagsLive.value, localSettings), + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), ) if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { @@ -284,16 +310,604 @@ class ActionsViewModel @Inject constructor( return uidsToMove } + //region Delete + fun deleteThreadsOrMessages( + threads: List? = null, + messages: List? = null, + currentFolder: Folder?, + mailbox: Mailbox + ) = + viewModelScope.launch(ioCoroutineContext) { + val messagesToDelete = getMessagesToDelete(threads, messages) + deleteMessages(messagesToDelete, currentFolder, mailbox) + } + + private fun deleteMessages( + messages: List, + currentFolder: Folder?, + mailbox: Mailbox + ) = viewModelScope.launch(ioCoroutineContext) { + + val shouldPermanentlyDelete = + isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messages, currentFolder)) + + if (shouldPermanentlyDelete) { + permanentlyDelete( + messagesToDelete = messages, + currentFolder = currentFolder, + mailbox = mailbox + ) + } else { + moveMessagesTo( + destinationFolder = folderController.getFolder(FolderRole.TRASH)!!, + messages = messages, + currentFolderId = currentFolder?.id, + mailbox = mailbox + ) + } + } + + private suspend fun permanentlyDelete( + messagesToDelete: List, + currentFolder: Folder?, + mailbox: Mailbox + ) { + val undoResources = emptyList() + val uids = messagesToDelete.getUids() + + val uidsToMove = moveOutThreadsLocally(messagesToDelete, folderController.getFolder(FolderRole.TRASH)!!) + + val apiResponses = ApiRepository.deleteMessages( + mailboxUuid = mailbox.uuid, + messagesUids = uids, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(mailbox.featureFlags, localSettings) + ) + + activityDialogLoaderResetTrigger.postValue(Unit) + + if (apiResponses.atLeastOneSucceeded()) { + + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messagesToDelete.getFoldersIds(), + currentFolderId = currentFolder?.id, + callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(uidsToMove) }), + ) + } + + if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( + 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, + // TODO: calculate threads? + numberOfImpactedThreads = messagesToDelete.count(), + ) + } + + private fun showDeleteSnackbar( + apiResponses: List>, + messages: List, + undoResources: List, + undoFoldersIds: ImpactedFolders, + undoDestinationId: String?, + numberOfImpactedThreads: Int, + ) { + val snackbarTitle = if (apiResponses.atLeastOneSucceeded()) { + if (messages.count() > 1) { + // TODO: Do a string for multiple messages deleted + appContext.resources.getQuantityString( + R.plurals.snackbarThreadDeletedPermanently, + numberOfImpactedThreads + ) + } else { + appContext.getString(R.string.snackbarMessageDeletedPermanently) + } + } else { + appContext.getString(apiResponses.first().translateError()) + } + + val undoData = if (undoResources.isEmpty()) 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, + currentFolder: Folder?, + mailbox: Mailbox + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolder?.id) + archiveMessages(messagesToMove, currentFolder, mailbox) + } + + private fun archiveMessages( + messages: List, + currentFolder: Folder?, + mailbox: Mailbox + ) = viewModelScope.launch(ioCoroutineContext) { + + 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)!! + + moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) + } + private fun refreshFoldersAsync( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders, - currentFolderId: String, + currentFolderId: String? = null, destinationFolderId: String? = null, callbacks: RefreshCallbacks? = null, ) = viewModelScope.launch(ioCoroutineContext) { sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) } + //region Seen + + fun toggleThreadsOrMessagesSeenStatus( + threadsUids: List? = null, + messages: List? = null, + shouldRead: Boolean = true, + currentFolderId: String?, + mailbox: Mailbox + ) { + toggleMessagesSeenStatus(threadsUids, messages, shouldRead = shouldRead, currentFolderId, mailbox) + } + + private fun toggleMessagesSeenStatus( + threadsUids: List? = null, + messages: List? = null, + shouldRead: Boolean = true, + currentFolderId: String?, + 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), + ) + } + } + + 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 -> + //TODO: CHECK IF FEATURE FLAG IS OK LIKE THIS + 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, + shouldFavorite: Boolean = true, + 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) + } + } + + 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) + } + } + + //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, + currentFolderId = currentFolder?.id, + mailbox = mailbox, + displaySnackbar = false + ) + } + + + R.string.snackbarReportPhishingConfirmation + } else { + translateError() + } + + reportPhishingTrigger.postValue(Unit) + snackbarManager.postValue(appContext.getString(snackbarTitle)) + } + } + } + //endregion + + //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) + } + } + //endregion + + //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 + + 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) + } + + snackbarManager.postValue(userFeedbackMessage) + } + }.join() + + return isSuccess + } + + 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 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 + } + + 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)), + ) + } + + private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { + val errorMessageRes = when (errorResult) { + BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedModify + is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError + BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred + } + return appContext.getString(errorMessageRes) + } + + suspend fun unsnoozeThreads(threads: Collection, mailbox: Mailbox?): BatchSnoozeResult { + var unsnoozeResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown + + 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) + } + }.join() + + return unsnoozeResult + } + + private fun getUnsnoozeErrorMessage(errorResult: BatchSnoozeResult.Error): String { + val errorMessageRes = when (errorResult) { + BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedCancel + is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError + BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred + } + + return appContext.getString(errorMessageRes) + } + //endregion + +//region Emoji reaction + /** + * Wrapper method to send an emoji reaction to the api. This method will check if the emoji reaction is allowed before + * initiating an api call. This is the entry point to add an emoji reaction anywhere in the app. + * + * If sending is allowed, the caller place can fake the emoji reaction locally thanks to [onAllowed]. + * If sending is not allowed, it will display the error directly to the user and avoid doing the api call. + */ + fun trySendEmojiReply( + emoji: String, + messageUid: String, + reactions: Map, + hasNetwork: Boolean, + mailbox: Mailbox, + onAllowed: () -> Unit = {}, + ) { + viewModelScope.launch { + when (val status = reactions.getEmojiSendStatus(emoji, hasNetwork)) { + EmojiSendStatus.Allowed -> { + onAllowed() + sendEmojiReply(emoji, messageUid, mailbox) + } + is EmojiSendStatus.NotAllowed -> snackbarManager.postValue(appContext.getString(status.errorMessageRes)) + } + } + } + + private fun Map.getEmojiSendStatus(emoji: String, hasNetwork: Boolean): EmojiSendStatus = when { + this[emoji]?.hasReacted == true -> EmojiSendStatus.NotAllowed.AlreadyUsed + hasAvailableReactionSlot().not() -> EmojiSendStatus.NotAllowed.MaxReactionReached + hasNetwork.not() -> EmojiSendStatus.NotAllowed.NoInternet + else -> EmojiSendStatus.Allowed + } + + /** + * The actual logic of sending an emoji reaction to the api. This method initializes a [Draft] instance, stores it into the + * database and schedules the [DraftsActionsWorker] so the draft is uploaded on the api. + */ + private suspend fun sendEmojiReply(emoji: String, messageUid: String, mailbox: Mailbox) { + val targetMessage = messageController.getMessage(messageUid) ?: return + val (fullMessage, hasFailedFetching) = draftController.fetchHeavyDataIfNeeded(targetMessage) + if (hasFailedFetching) return + val draftMode = Draft.DraftMode.REPLY_ALL + + val draft = Draft().apply { + with(draftInitManager) { + setPreviousMessage(draftMode, fullMessage) + } + + val quote = draftInitManager.createQuote(draftMode, fullMessage, attachments) + body = EMOJI_REACTION_PLACEHOLDER + quote + + with(draftInitManager) { + // We don't want to send the HTML code of the signature for an emoji reaction but we still need to send the + // identityId stored in a Signature + val signature = chooseSignature(mailbox.email, mailbox.signatures, draftMode, fullMessage) + setSignatureIdentity(signature) + } + + mimeType = Utils.TEXT_HTML + + action = Draft.DraftAction.SEND_REACTION + emojiReaction = emoji + } + + draftController.upsertDraft(draft) + + draftsActionsWorkerScheduler.scheduleWork(draft.localUuid, AccountUtils.currentMailboxId, AccountUtils.currentUserId) + } + + private sealed interface EmojiSendStatus { + data object Allowed : EmojiSendStatus + + sealed class NotAllowed(@StringRes val errorMessageRes: Int) : EmojiSendStatus { + data object AlreadyUsed : NotAllowed(ErrorCode.EmojiReactions.alreadyUsed.translateRes) + data object MaxReactionReached : NotAllowed(ErrorCode.EmojiReactions.maxReactionReached.translateRes) + data object NoInternet : NotAllowed(RCore.string.noConnection) + } + } + //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() + + val snackbarTitle = when { + failedCall == null -> R.string.snackbarMoveCancelled + else -> failedCall.translateError() + } + + snackbarManager.postValue(appContext.getString(snackbarTitle)) + } + //endregion + private fun onDownloadStart() { isDownloadingChanges.postValue(true) } @@ -303,4 +917,7 @@ class ActionsViewModel @Inject constructor( isDownloadingChanges.postValue(false) } + companion object { + private const val EMOJI_REACTION_PLACEHOLDER = "
__REACTION_PLACEMENT__
" + } } 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 3bbcc4f45b..8ef8d87adb 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 @@ -22,7 +22,6 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.infomaniak.core.common.extensions.isNightModeEnabled @@ -53,6 +52,9 @@ import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDialog import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.common.R as RCore @@ -73,6 +75,9 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { @Inject lateinit var descriptionDialog: DescriptionAlertDialog + @Inject + lateinit var globalCoroutineScope: CoroutineScope + @Inject lateinit var folderRoleUtils: FolderRoleUtils @@ -82,7 +87,8 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(navigationArgs) { super.onViewCreated(view, savedInstanceState) binding.print.isVisible = true - viewLifecycleOwner.lifecycleScope.launch { + // Use a scope not tied to the DialogFragment's view lifecycle so it keeps running after the sheet is closed. + globalCoroutineScope.launch(Dispatchers.Main.immediate, start = CoroutineStart.UNDISPATCHED) { // Initialization of threadsUids to populate junkMessages and potentialUsersToBlock junkMessagesViewModel.threadsUids = listOf(threadUid) @@ -98,6 +104,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { observeReportPhishingResult() observePotentialBlockedSenders() + observeSpamTrigger() if (requireContext().isNightModeEnabled()) { binding.lightTheme.apply { @@ -113,7 +120,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } private fun observeReportPhishingResult() { - mainViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { + actionsViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { descriptionDialog.resetLoadingAndDismiss() findNavController().popBackStack() } @@ -125,6 +132,12 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } } + private fun observeSpamTrigger() { + actionsViewModel.spamTrigger.observe(viewLifecycleOwner) { + findNavController().popBackStack() + } + } + private fun initActionClickListener(messageUid: String, message: Message, threadUid: String) { initOnClickListener(object : OnActionClick { //region Main actions @@ -161,7 +174,11 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Delete) - mainViewModel.deleteMessage(threadUid, message) + actionsViewModel.deleteThreadsOrMessages( + messages = listOf(message), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } //endregion @@ -170,13 +187,21 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(message.folder.role, count = 1) { trackBottomSheetMessageActionsEvent(MatomoName.Archive, message.folder.role == FolderRole.ARCHIVE) - mainViewModel.archiveMessage(threadUid, message) + actionsViewModel.archiveThreadsOrMessages( + messages = listOf(message), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } override fun onReadUnread() { trackBottomSheetMessageActionsEvent(MatomoName.MarkAsSeen, message.isSeen) - mainViewModel.toggleMessageSeenStatus(threadUid, message) + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + messages = listOf(message), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) twoPaneViewModel.closeThread() } @@ -209,15 +234,18 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onFavorite() { trackBottomSheetMessageActionsEvent(MatomoName.Favorite, message.isFavorite) - mainViewModel.toggleMessageFavoriteStatus(threadUid, message) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + messages = listOf(message), + mailbox = mainViewModel.currentMailbox.value!! + ) } override fun onSpam() { trackBottomSheetMessageActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleMessagesSpamStatus( - listOf(message), - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + messages = listOf(message), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) } @@ -226,7 +254,13 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { descriptionDialog.show( title = getString(R.string.reportPhishingTitle), description = resources.getQuantityString(R.plurals.reportPhishingDescription, 1), - onPositiveButtonClicked = { mainViewModel.reportPhishing(listOf(threadUid), listOf(message)) }, + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = listOf(message), + currentFolder = mainViewModel.currentFolder.value, + 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 b69319ea24..32c7a201a4 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 @@ -111,7 +111,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { lifecycleScope.launch { val folderRole = folderRoleUtils.getActionFolderRole(threads) - setupMainActions(threadsCount, threadsUids, shouldRead, folderRole) + setupMainActions(threads, threadsUids, shouldRead, folderRole) } setStateDependentUi(shouldRead, shouldFavorite, isFromArchive, threads) @@ -133,17 +133,16 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.cancelSnooze.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.CancelSnooze, threadsCount, isFromBottomSheet = true) - lifecycleScope.launch { mainViewModel.unsnoozeThreads(threads) } + lifecycleScope.launch { actionsViewModel.unsnoozeThreads(threads, mainViewModel.currentMailbox.value) } isMultiSelectOn = false } binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - val threadMessages = threads.flatMap { it.messages } - actionsViewModel.toggleMessagesSpamStatus( - threadMessages, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = threads, + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!!, ) isMultiSelectOn = false } @@ -161,7 +160,13 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { descriptionDialog.show( title = getString(R.string.reportPhishingTitle), description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), - onPositiveButtonClicked = { mainViewModel.reportPhishing(threadsUids, messages) }, + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = messages, + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) + }, ) } @@ -189,7 +194,11 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - toggleThreadsFavoriteStatus(threadsUids, shouldFavorite) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids, + mailbox = currentMailbox.value!!, + shouldFavorite = shouldFavorite + ) isMultiSelectOn = false } @@ -204,17 +213,33 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { } private fun setupMainActions( - threadsCount: Int, + threads: Set, threadsUids: List, shouldRead: Boolean, folderRole: FolderRole? ) { binding.mainActions.setClosingOnClickListener(shouldCloseMultiSelection = true) { id: Int -> + val currentMailbox = mainViewModel.currentMailbox.value ?: run { + SentryLog.e(TAG, "Mailbox is null but shouldn't") { scope -> + scope.setTag("context", "$TAG.setupMailAction") + } + return@setClosingOnClickListener + } + + val threadsCount = threads.count() + val currentFolder = mainViewModel.currentFolder.value + val currentFolderId = mainViewModel.currentFolderId + when (id) { R.id.actionMove -> onMoveClicked(threadsCount, threadsUids, folderRole) R.id.actionReadUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, threadsCount, isFromBottomSheet = true) - mainViewModel.toggleThreadsSeenStatus(threadsUids, shouldRead) + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + threadsUids, + shouldRead = shouldRead, + currentFolderId = currentFolderId, + mailbox = currentMailbox + ) } R.id.actionArchive -> { descriptionDialog.archiveWithConfirmationPopup( @@ -222,7 +247,11 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Archive, threadsCount, isFromBottomSheet = true) - mainViewModel.archiveThreads(threadsUids) + actionsViewModel.archiveThreadsOrMessages( + threads = threads.toList(), + currentFolder = currentFolder, + mailbox = currentMailbox + ) } } R.id.actionDelete -> { @@ -231,7 +260,11 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) - mainViewModel.deleteThreads(threadsUids) + actionsViewModel.deleteThreadsOrMessages( + threads = threads.toList(), + currentFolder = currentFolder, + mailbox = currentMailbox + ) } } } @@ -260,7 +293,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { } private fun observeReportPhishingResult() { - mainViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { + actionsViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { descriptionDialog.resetLoadingAndDismiss() findNavController().popBackStack() } 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 b5f85f065b..2e8dfacf50 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 @@ -117,6 +117,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { observePotentialBlockedUsers() observeReportPhishingResult() + observeSpamTrigger() } private fun setSnoozeUi(isThreadSnoozed: Boolean) = with(binding) { @@ -134,12 +135,18 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } private fun observeReportPhishingResult() { - mainViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { + actionsViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { descriptionDialog.resetLoadingAndDismiss() findNavController().popBackStack() } } + private fun observeSpamTrigger() { + actionsViewModel.spamTrigger.observe(viewLifecycleOwner) { + findNavController().popBackStack() + } + } + private fun onActionClick( thread: Thread, messageUidToExecuteAction: String, @@ -179,7 +186,11 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onDelete() { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Delete) - mainViewModel.deleteThread(navigationArgs.threadUid) + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } //endregion @@ -188,13 +199,21 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onArchive() { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackBottomSheetThreadActionsEvent(MatomoName.Archive, isFromArchive) - mainViewModel.archiveThread(navigationArgs.threadUid) + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } override fun onReadUnread() { trackBottomSheetThreadActionsEvent(MatomoName.MarkAsSeen, value = thread.isSeen) - mainViewModel.toggleThreadSeenStatus(navigationArgs.threadUid) + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + threadsUids = listOf(navigationArgs.threadUid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) twoPaneViewModel.closeThread() } @@ -230,21 +249,24 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onCancelSnooze() { trackBottomSheetThreadActionsEvent(MatomoName.CancelSnooze) - lifecycleScope.launch { mainViewModel.unsnoozeThreads(listOf(thread)) } + lifecycleScope.launch { actionsViewModel.unsnoozeThreads(listOf(thread), mainViewModel.currentMailbox.value) } twoPaneViewModel.closeThread() } override fun onFavorite() { trackBottomSheetThreadActionsEvent(MatomoName.Favorite, thread.isFavorite) - mainViewModel.toggleThreadFavoriteStatus(navigationArgs.threadUid) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = listOf(navigationArgs.threadUid), + mailbox = mainViewModel.currentMailbox.value!! + ) } override fun onSpam() { trackBottomSheetThreadActionsEvent(MatomoName.Spam, value = isFromSpam) - actionsViewModel.toggleMessagesSpamStatus( - thread.messages, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = setOf(thread), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) } @@ -261,7 +283,13 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { descriptionDialog.show( title = getString(R.string.reportPhishingTitle), description = resources.getQuantityString(R.plurals.reportPhishingDescription, thread.messages.count()), - onPositiveButtonClicked = { mainViewModel.reportPhishing(junkMessagesViewModel.threadsUids, junkMessages) }, + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = junkMessages, + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) + }, ) } 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 7ba8841e36..14f12067ee 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/FolderRoleUtils.kt @@ -18,6 +18,7 @@ package com.infomaniak.mail.utils import com.infomaniak.mail.data.cache.mailboxContent.FolderController +import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.Snoozable import com.infomaniak.mail.data.models.isSnoozed @@ -43,6 +44,28 @@ class FolderRoleUtils @Inject constructor( return getActionFolderRole(message.folderId, message) } + /** + * Get the FolderRole of a list of Messages. + * + * @param messages The list of Messages to find the FolderRole. They can be from different folders because of the + * Multiselect in the Search. + * + * If there is only one message, we will return the folder role of that message. + * If there are multiple messages and the user is not searching every message will be from the same folder (currentFolder). + * + * --> These cases will be added when the Multiselection in search is added. + * If the user is searching and he has filtered by a folder we will return the folder role of that selected folder (filterFolder). + * If the user is searching and he hasn't filtered by a folder, we will do the same action for all of them independently + * where it comes from, so we will get the folder role of the first message. + */ + suspend fun getActionFolderRole(messages: List, selectedFolder: Folder?): FolderRole? { + val folderRole = when { + messages.count() == 1 -> getActionFolderRole(messages.first()) + else -> selectedFolder?.role + } + return folderRole + } + /** * Get the FolderRole of a Message or a list of Threads. * 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 4d358d9149..d0dd0a437f 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -101,6 +101,33 @@ class SharedUtils @Inject constructor( 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) @@ -108,11 +135,23 @@ class SharedUtils @Inject constructor( } } + 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 { + threads != null -> threads.flatMap { messageController.getMovableMessages(it) } + messages != null -> messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } + else -> emptyList() //this should never happen, we have to send a list of threads or messages. + } + suspend fun refreshFolders( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders, From 0c30b1b262ed847728965902ddbda39019129cc3 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 30 Jan 2026 11:37:39 +0100 Subject: [PATCH 03/13] fix: Fix one message actions --- .../java/com/infomaniak/mail/ui/MainViewModel.kt | 10 +++++----- .../ui/main/folderPicker/FolderPickerFragment.kt | 6 +++--- .../ui/main/thread/actions/ActionsViewModel.kt | 3 +-- .../thread/actions/MailActionsBottomSheetDialog.kt | 2 +- .../actions/MessageActionsBottomSheetDialog.kt | 14 +++----------- .../actions/ThreadActionsBottomSheetDialog.kt | 7 ------- .../java/com/infomaniak/mail/utils/SharedUtils.kt | 2 +- app/src/main/res/navigation/main_navigation.xml | 4 ++-- 8 files changed, 16 insertions(+), 32 deletions(-) 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 a19bad4c88..ddc53d2f53 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -865,14 +865,14 @@ class MainViewModel @Inject constructor( fun moveThreadsOrMessageTo( destinationFolderId: String, threadsUids: List, - messageUid: String? = null, + messagesUid: List? = null, ) = viewModelScope.launch(ioCoroutineContext) { val destinationFolder = folderController.getFolder(destinationFolderId)!! val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - val message = messageUid?.let { messageController.getMessage(it)!! } - val messagesToMove = sharedUtils.getMessagesToMove(threads, message) + val messages = messagesUid?.let { messageController.getMessages(it) } + val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) - moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, message, messagesToMove) + moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, null, messagesToMove) } private suspend fun moveThreadsOrMessageTo( @@ -1559,7 +1559,7 @@ class MainViewModel @Inject constructor( fun moveToNewFolder( name: String, threadsUids: List, - messageUid: String?, + messageUid: List?, ) = viewModelScope.launch(ioCoroutineContext) { val newFolderId = createNewFolderSync(name) ?: return@launch moveThreadsOrMessageTo(newFolderId, threadsUids, messageUid) 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 6fc607e67e..c8867cd753 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 @@ -127,7 +127,7 @@ class FolderPickerFragment : Fragment() { private fun setupCreateFolderDialog() = with(navigationArgs) { createFolderDialog.setCallbacks( onPositiveButtonClicked = { folderName -> - mainViewModel.moveToNewFolder(folderName, threadsUids.toList(), messageUid) + mainViewModel.moveToNewFolder(folderName, threadsUids.toList(), messagesUids?.toList()) }, ) } @@ -155,8 +155,8 @@ class FolderPickerFragment : Fragment() { FolderPickerAction.MOVE -> folder?.id?.let { actionsViewModel.moveThreadsOrMessagesTo( it, - threadsUids.toList(), - messageUid?.let { listOf(messageUid) }, + threadsUids?.let { threadsUids.toList() }, + messagesUids?.let { messagesUids.toList() }, mainViewModel.currentFolderId, mainViewModel.currentMailbox.value!! ) 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 b1dd411235..1748883ecb 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 @@ -98,7 +98,7 @@ class ActionsViewModel @Inject constructor( private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) val isDownloadingChanges: MutableLiveData = MutableLiveData(false) val activityDialogLoaderResetTrigger = SingleLiveEvent() - val spamTrigger = SingleLiveEvent() + val isMovedToNewFolder = SingleLiveEvent() val reportPhishingTrigger = SingleLiveEvent() //region Spam @@ -144,7 +144,6 @@ class ActionsViewModel @Inject constructor( val messages = messageController.getUnscheduledMessages(messages) moveMessagesTo(destinationFolder, currentFolderId, mailbox, messages, displaySnackbar) - spamTrigger.postValue(Unit) } fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt index f34239c020..37be708118 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/MailActionsBottomSheetDialog.kt @@ -93,7 +93,7 @@ abstract class MailActionsBottomSheetDialog : ActionsBottomSheetDialog() { modifySnooze.setOnClickListener { onClickListener.onModifySnooze() } cancelSnooze.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onCancelSnooze() } favorite.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onFavorite() } - spam.setOnClickListener { onClickListener.onSpam() } + spam.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onSpam() } phishing.setOnClickListener { onClickListener.onPhishing() } blockSender.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onBlockSender() } print.setClosingOnClickListener(shouldCloseMultiSelection) { onClickListener.onPrint() } 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 8ef8d87adb..acfa70f590 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 @@ -21,7 +21,6 @@ import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.infomaniak.core.common.extensions.isNightModeEnabled @@ -64,7 +63,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: MessageActionsBottomSheetDialogArgs by navArgs() - private val actionsViewModel: ActionsViewModel by viewModels() + private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val currentClassName: String by lazy { MessageActionsBottomSheetDialog::class.java.name } @@ -104,7 +103,6 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { observeReportPhishingResult() observePotentialBlockedSenders() - observeSpamTrigger() if (requireContext().isNightModeEnabled()) { binding.lightTheme.apply { @@ -132,12 +130,6 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } } - private fun observeSpamTrigger() { - actionsViewModel.spamTrigger.observe(viewLifecycleOwner) { - findNavController().popBackStack() - } - } - private fun initActionClickListener(messageUid: String, message: Message, threadUid: String) { initOnClickListener(object : OnActionClick { //region Main actions @@ -212,9 +204,9 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { navController.animatedNavigation( resId = R.id.folderPickerFragment, args = FolderPickerFragmentArgs( - threadsUids = arrayOf(threadUid), action = FolderPickerAction.MOVE, - messageUid = messageUid, + threadsUids = arrayOf(threadUid), + messagesUids = arrayOf(messageUid), sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID ).toBundle(), ) 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 2e8dfacf50..da55e45de6 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 @@ -117,7 +117,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { observePotentialBlockedUsers() observeReportPhishingResult() - observeSpamTrigger() } private fun setSnoozeUi(isThreadSnoozed: Boolean) = with(binding) { @@ -141,12 +140,6 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } } - private fun observeSpamTrigger() { - actionsViewModel.spamTrigger.observe(viewLifecycleOwner) { - findNavController().popBackStack() - } - } - private fun onActionClick( thread: Thread, messageUidToExecuteAction: String, 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 d0dd0a437f..476d895dd0 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt @@ -147,8 +147,8 @@ class SharedUtils @Inject constructor( } suspend fun getMessagesToMove(threads: List?, messages: List?, currentFolderId: String?) = when { - threads != null -> threads.flatMap { messageController.getMovableMessages(it) } 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. } diff --git a/app/src/main/res/navigation/main_navigation.xml b/app/src/main/res/navigation/main_navigation.xml index 17866b587b..c6b173b215 100644 --- a/app/src/main/res/navigation/main_navigation.xml +++ b/app/src/main/res/navigation/main_navigation.xml @@ -276,9 +276,9 @@ android:name="threadsUids" app:argType="string[]" /> Date: Fri, 30 Jan 2026 14:12:26 +0100 Subject: [PATCH 04/13] refactor: Remove unnused functions from MainViewModel and added unblock functions to actionsViewModel --- .../com/infomaniak/mail/ui/MainViewModel.kt | 672 ------------------ .../mail/ui/main/thread/ThreadFragment.kt | 4 +- .../main/thread/actions/ActionsViewModel.kt | 33 +- 3 files changed, 33 insertions(+), 676 deletions(-) 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 ddc53d2f53..0e9de75e55 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -18,7 +18,6 @@ package com.infomaniak.mail.ui import android.app.Application -import androidx.annotation.StringRes import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData @@ -39,14 +38,12 @@ import com.infomaniak.core.network.networking.ManualAuthorizationRequired import com.infomaniak.core.network.utils.ApiErrorCode.Companion.translateError import com.infomaniak.core.sentry.SentryLog import com.infomaniak.core.ui.showToast -import com.infomaniak.emojicomponents.data.Reaction import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackMultiSelectionEvent 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.DraftController import com.infomaniak.mail.data.cache.mailboxContent.FolderController import com.infomaniak.mail.data.cache.mailboxContent.ImpactedFolders import com.infomaniak.mail.data.cache.mailboxContent.MessageController @@ -64,14 +61,10 @@ import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.FolderUi import com.infomaniak.mail.data.models.MoveResult import com.infomaniak.mail.data.models.correspondent.Recipient -import com.infomaniak.mail.data.models.draft.Draft import com.infomaniak.mail.data.models.forEachNestedItem -import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.mailbox.Mailbox.FeatureFlagSet -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.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.di.IoDispatcher @@ -81,42 +74,31 @@ import com.infomaniak.mail.ui.main.SnackbarManager.UndoData 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.DraftInitManager -import com.infomaniak.mail.utils.EmojiReactionUtils.hasAvailableReactionSlot -import com.infomaniak.mail.utils.ErrorCode import com.infomaniak.mail.utils.FeatureAvailability -import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.MyKSuiteDataUtils -import com.infomaniak.mail.utils.NotificationUtils import com.infomaniak.mail.utils.NotificationUtils.Companion.cancelNotification import com.infomaniak.mail.utils.SharedUtils -import com.infomaniak.mail.utils.SharedUtils.Companion.unsnoozeThreadsWithoutRefresh import com.infomaniak.mail.utils.SharedUtils.Companion.updateSignatures import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.Utils.EML_CONTENT_TYPE -import com.infomaniak.mail.utils.Utils.isPermanentDeleteFolder import com.infomaniak.mail.utils.Utils.runCatchingRealm import com.infomaniak.mail.utils.coroutineContext -import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear 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.getFirstTranslatedError 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 import com.infomaniak.mail.views.itemViews.KSuiteStorageBanner.StorageLevel -import com.infomaniak.mail.workers.DraftsActionsWorker import dagger.hilt.android.lifecycle.HiltViewModel import io.realm.kotlin.MutableRealm import io.realm.kotlin.Realm import io.realm.kotlin.ext.toRealmList import io.realm.kotlin.notifications.ResultsChange -import io.realm.kotlin.query.RealmResults import io.sentry.Attachment import io.sentry.Sentry import io.sentry.SentryLevel @@ -138,7 +120,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.invoke import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date @@ -154,18 +135,13 @@ class MainViewModel @Inject constructor( application: Application, avatarMergedContactData: AvatarMergedContactData, private val addressBookController: AddressBookController, - private val draftController: DraftController, - private val draftInitManager: DraftInitManager, - private val draftsActionsWorkerScheduler: DraftsActionsWorker.Scheduler, private val folderController: FolderController, - private val folderRoleUtils: FolderRoleUtils, private val localSettings: LocalSettings, private val mailboxContentRealm: RealmDatabase.MailboxContent, private val mailboxController: MailboxController, private val mergedContactController: MergedContactController, private val messageController: MessageController, private val myKSuiteDataUtils: MyKSuiteDataUtils, - private val notificationUtils: NotificationUtils, private val permissionsController: PermissionsController, private val quotasController: QuotasController, private val refreshController: RefreshController, @@ -502,43 +478,6 @@ class MainViewModel @Inject constructor( } //region Spam - fun moveToSpamFolder(threadUid: String, messageUid: String) = viewModelScope.launch(ioCoroutineContext) { - val message = messageController.getMessage(messageUid) ?: return@launch - toggleMessageSpamStatus(threadUid, message) - } - - fun activateSpamFilter() = viewModelScope.launch(ioCoroutineContext) { - val mailbox = currentMailbox.value ?: return@launch - - ApiRepository.setSpamFilter( - mailboxHostingId = mailbox.hostingId, - mailboxName = mailbox.mailboxName, - activateSpamFilter = true, - ) - } - - fun unblockMail(email: String) = viewModelScope.launch(ioCoroutineContext) { - val mailbox = currentMailbox.value ?: return@launch - - with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { - if (isSuccess()) { - val updatedSendersRestrictions = data!!.apply { - blockedSenders.removeIf { it.email == email } - } - updateBlockedSenders(mailbox, updatedSendersRestrictions) - } - } - } - - 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 - } - } - } - } private fun updateSendersRestrictions(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { SentryLog.d(TAG, "Force refresh Senders Restrictions") @@ -672,117 +611,6 @@ class MainViewModel @Inject constructor( } //region Delete - fun deleteMessage(threadUid: String, message: Message) { - deleteThreadsOrMessage(threadsUids = listOf(threadUid), message = message) - } - - fun deleteThread(threadUid: String) { - deleteThreadsOrMessage(threadsUids = listOf(threadUid)) - } - - fun deleteThreads(threadsUids: List) { - deleteThreadsOrMessage(threadsUids = threadsUids) - } - - // TODO: When the back is done refactoring how scheduled drafts are deleted, work on this function shall resume. - private fun deleteThreadsOrMessage( - threadsUids: List, - message: Message? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - val shouldPermanentlyDelete = isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(threads, message)) - val messages = getMessagesToDelete(threads, message) - - if (shouldPermanentlyDelete) { - permanentlyDelete( - threads = threads, - threadsUids = threadsUids, - messagesToDelete = messages, - message = message, - ) - } else { - moveThreadsOrMessageTo( - destinationFolder = folderController.getFolder(FolderRole.TRASH)!!, - threadsUids = threadsUids, - threads = threads, - message = message, - messagesToMove = messages, - ) - } - } - - private suspend fun permanentlyDelete( - threads: RealmResults, - threadsUids: List, - messagesToDelete: List, - message: Message?, - ) { - val mailbox = currentMailbox.value!! - val undoResources = emptyList() - val uids = messagesToDelete.getUids() - - moveOutThreadsLocally(threadsUids, threads, message) - - val apiResponses = ApiRepository.deleteMessages( - mailboxUuid = mailbox.uuid, - messagesUids = uids, - alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlagsLive.value, localSettings) - ) - - activityDialogLoaderResetTrigger.postValue(Unit) - - if (apiResponses.atLeastOneSucceeded()) { - if (shouldAutoAdvance(message, threadsUids)) autoAdvanceThreadsUids.postValue(threadsUids) - - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messagesToDelete.getFoldersIds(), - callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(threadsUids) }), - ) - } - - if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) - - val undoDestinationId = message?.folderId ?: threads.first().folderId - val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) - showDeleteSnackbar( - apiResponses = apiResponses, - message = message, - undoResources = undoResources, - undoFoldersIds = undoFoldersIds, - undoDestinationId = undoDestinationId, - numberOfImpactedThreads = threads.count(), - ) - } - - private fun showDeleteSnackbar( - apiResponses: List>, - message: Message?, - undoResources: List, - undoFoldersIds: ImpactedFolders, - undoDestinationId: String?, - numberOfImpactedThreads: Int, - ) { - val snackbarTitle = if (apiResponses.atLeastOneSucceeded()) { - if (message == null) { - appContext.resources.getQuantityString(R.plurals.snackbarThreadDeletedPermanently, numberOfImpactedThreads) - } else { - appContext.getString(R.string.snackbarMessageDeletedPermanently) - } - } else { - appContext.getString(apiResponses.first().translateError()) - } - - val undoData = if (undoResources.isEmpty()) null else UndoData(undoResources, undoFoldersIds, undoDestinationId) - - snackbarManager.postValue(snackbarTitle, undoData) - } - - private suspend fun getMessagesToDelete(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } fun deleteDraft(targetMailboxUuid: String, remoteDraftUuid: String) = viewModelScope.launch(ioCoroutineContext) { val mailbox = currentMailbox.value!! @@ -987,260 +815,6 @@ class MainViewModel @Inject constructor( } //endregion - //region Archive - fun archiveMessage(threadUid: String, message: Message) { - archiveThreadsOrMessage(threadsUids = listOf(threadUid), message = message) - } - - fun archiveThread(threadUid: String) { - archiveThreadsOrMessage(threadsUids = listOf(threadUid)) - } - - fun archiveThreads(threadsUids: List) { - archiveThreadsOrMessage(threadsUids = threadsUids) - } - - private fun archiveThreadsOrMessage( - threadsUids: List, - message: Message? = null, - ) = viewModelScope.launch(ioCoroutineContext) { - - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - - val role = folderRoleUtils.getActionFolderRole(threads, message) - val isFromArchive = role == FolderRole.ARCHIVE - - val destinationFolderRole = if (isFromArchive) FolderRole.INBOX else FolderRole.ARCHIVE - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - - val messagesToMove = sharedUtils.getMessagesToMove(threads, message) - - moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, message, messagesToMove) - } - //endregion - - //region Seen - fun toggleMessageSeenStatus(threadUid: String, message: Message) { - toggleThreadsOrMessageSeenStatus(threadsUids = listOf(threadUid), message = message) - } - - fun toggleThreadSeenStatus(threadUid: String) { - toggleThreadsOrMessageSeenStatus(threadsUids = listOf(threadUid)) - } - - fun toggleThreadsSeenStatus(threadsUids: List, shouldRead: Boolean) { - toggleThreadsOrMessageSeenStatus(threadsUids = threadsUids, shouldRead = shouldRead) - } - - private fun toggleThreadsOrMessageSeenStatus( - threadsUids: List, - message: Message? = null, - shouldRead: Boolean = true, - ) = viewModelScope.launch(ioCoroutineContext) { - val mailbox = currentMailbox.value!! - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - - val isSeen = when { - message != null -> message.isSeen - threads.count() == 1 -> threads.single().isSeen - else -> !shouldRead - } - - if (isSeen) { - markAsUnseen(mailbox, threads, message) - } else { - sharedUtils.markAsSeen( - mailbox = mailbox, - threads = threads, - message = message, - currentFolderId = currentFolderId, - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } - } - - private suspend fun markAsUnseen( - mailbox: Mailbox, - threads: List, - message: Message? = null, - ) { - - val messages = getMessagesToMarkAsUnseen(threads, message) - val threadsUids = threads.map { it.uid } - val messagesUids = messages.map { it.uid } - - updateSeenStatus(threadsUids, messagesUids, isSeen = false) - - val apiResponses = ApiRepository.markMessagesAsUnseen(mailbox.uuid, messagesUids) - - if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( - mailbox = mailbox, - messagesFoldersIds = messages.getFoldersIds(), - callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), - ) - } else { - updateSeenStatus(threadsUids, messagesUids, isSeen = true) - } - } - - private suspend fun getMessagesToMarkAsUnseen(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, featureFlagsLive.value) - } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } - - private suspend fun updateSeenStatus(threadsUids: List, messagesUids: List, isSeen: Boolean) { - mailboxContentRealm().write { - MessageController.updateSeenStatus(messagesUids, isSeen, realm = this) - ThreadController.updateSeenStatus(threadsUids, isSeen, realm = this) - } - } - //endregion - - //region Favorite - fun toggleMessageFavoriteStatus(threadUid: String, message: Message) { - toggleThreadsOrMessageFavoriteStatus(threadsUids = listOf(threadUid), message = message) - } - - fun toggleThreadFavoriteStatus(threadUid: String) { - toggleThreadsOrMessageFavoriteStatus(threadsUids = listOf(threadUid)) - } - - fun toggleThreadsFavoriteStatus(threadsUids: List, shouldFavorite: Boolean) { - toggleThreadsOrMessageFavoriteStatus(threadsUids = threadsUids, shouldFavorite = shouldFavorite) - } - - private fun toggleThreadsOrMessageFavoriteStatus( - threadsUids: List, - message: Message? = null, - shouldFavorite: Boolean = true, - ) = viewModelScope.launch(ioCoroutineContext) { - val mailbox = currentMailbox.value!! - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - - val isFavorite = when { - message != null -> message.isFavorite - threads.count() == 1 -> threads.single().isFavorite - else -> !shouldFavorite - } - - val messages = if (isFavorite) { - getMessagesToUnfavorite(threads, message) - } else { - getMessagesToFavorite(threads, message) - } - val uids = messages.getUids() - - updateFavoriteStatus(threadsUids = threadsUids, 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(threadsUids = threadsUids, messagesUids = uids, isFavorite = isFavorite) - } - } - - private suspend fun getMessagesToFavorite(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { thread -> - messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, featureFlagsLive.value) - } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } - - private suspend fun getMessagesToUnfavorite(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getFavoriteMessages(it) } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } - - private suspend fun updateFavoriteStatus(threadsUids: List, messagesUids: List, isFavorite: Boolean) { - mailboxContentRealm().write { - MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) - ThreadController.updateFavoriteStatus(threadsUids, isFavorite, realm = this) - } - } - //endregion - - //region Spam - fun toggleMessageSpamStatus(threadUid: String, message: Message, displaySnackbar: Boolean = true) { - toggleThreadsOrMessageSpamStatus(threadsUids = listOf(threadUid), message = message, displaySnackbar = displaySnackbar) - } - - fun toggleThreadSpamStatus(threadUids: List, displaySnackbar: Boolean = true) { - toggleThreadsOrMessageSpamStatus(threadsUids = threadUids, displaySnackbar = displaySnackbar) - } - - private fun toggleThreadsOrMessageSpamStatus( - threadsUids: List, - message: Message? = null, - displaySnackbar: Boolean = true, - ) = viewModelScope.launch(ioCoroutineContext) { - - val threads = threadController.getThreads(threadsUids).ifEmpty { return@launch } - - val destinationFolderRole = if (folderRoleUtils.getActionFolderRole(threads, message) == FolderRole.SPAM) { - FolderRole.INBOX - } else { - FolderRole.SPAM - } - val destinationFolder = folderController.getFolder(destinationFolderRole)!! - - val messages = getMessagesToSpamOrHam(threads, message) - - moveThreadsOrMessageTo(destinationFolder, threadsUids, threads, message, messages, displaySnackbar) - } - - private suspend fun getMessagesToSpamOrHam(threads: List, message: Message?) = when (message) { - null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } - else -> listOf(message) - } - //endregion - - //region Phishing - fun reportPhishing(threadUids: List, messages: List) = viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = currentMailbox.value?.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()) { - // Check the first message, because it is not possible to select messages from multiple folders, - // so you won't have both SPAM and non-SPAM messages. - messages.firstOrNull()?.let { message -> - if (folderRoleUtils.getActionFolderRole(message) != FolderRole.SPAM) { - if (messages.count() == 1) { - toggleMessageSpamStatus(threadUids.first(), message, displaySnackbar = false) - } else { - toggleThreadSpamStatus(threadUids = threadUids, displaySnackbar = false) - } - } - } - - R.string.snackbarReportPhishingConfirmation - } else { - translateError() - } - - reportPhishingTrigger.postValue(Unit) - snackbarManager.postValue(appContext.getString(snackbarTitle)) - } - } - //endregion //region Display problem fun reportDisplayProblem(messageUid: String) = viewModelScope.launch(ioCoroutineContext) { @@ -1268,250 +842,6 @@ class MainViewModel @Inject constructor( } //endregion - //region BlockUser - fun blockUser(folderId: String, shortUid: Int) = viewModelScope.launch(ioCoroutineContext) { - val mailboxUuid = currentMailbox.value?.uuid!! - with(ApiRepository.blockUser(mailboxUuid, folderId, shortUid)) { - - val snackbarTitle = if (isSuccess()) R.string.snackbarBlockUserConfirmation else translateError() - snackbarManager.postValue(appContext.getString(snackbarTitle)) - - reportPhishingTrigger.postValue(Unit) - } - } - //endregion - - //region Snooze - suspend fun snoozeThreads(date: Date, threadUids: List): Boolean { - var isSuccess = false - - viewModelScope.launch { - currentMailbox.value?.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) - } - - snackbarManager.postValue(userFeedbackMessage) - } - }.join() - - return isSuccess - } - - suspend fun rescheduleSnoozedThreads(date: Date, threadUids: List): 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 currentMailbox = currentMailbox.value!! - val result = rescheduleSnoozedThreads(currentMailbox, snoozedThreadUuids, date) - - val userFeedbackMessage = when (result) { - is BatchSnoozeResult.Success -> { - refreshFoldersAsync(currentMailbox, result.impactedFolders) - - 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 - } - - 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)), - ) - } - - private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { - val errorMessageRes = when (errorResult) { - BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedModify - is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError - BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred - } - return appContext.getString(errorMessageRes) - } - - suspend fun unsnoozeThreads(threads: Collection): BatchSnoozeResult { - var unsnoozeResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown - - viewModelScope.launch(ioCoroutineContext) { - val currentMailbox = currentMailbox.value - unsnoozeResult = if (currentMailbox == null) { - BatchSnoozeResult.Error.Unknown - } else { - ioDispatcher { unsnoozeThreadsWithoutRefresh(scope = null, currentMailbox, threads) } - } - - unsnoozeResult.let { - val userFeedbackMessage = when (it) { - is BatchSnoozeResult.Success -> { - sharedUtils.refreshFolders(mailbox = currentMailbox!!, messagesFoldersIds = it.impactedFolders) - appContext.resources.getQuantityString(R.plurals.snackbarUnsnoozeSuccess, threads.count()) - } - is BatchSnoozeResult.Error -> getUnsnoozeErrorMessage(it) - } - - snackbarManager.postValue(userFeedbackMessage) - } - }.join() - - return unsnoozeResult - } - - private fun getUnsnoozeErrorMessage(errorResult: BatchSnoozeResult.Error): String { - val errorMessageRes = when (errorResult) { - BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedCancel - is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError - BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred - } - - return appContext.getString(errorMessageRes) - } - //endregion - - //region Emoji reaction - /** - * Wrapper method to send an emoji reaction to the api. This method will check if the emoji reaction is allowed before - * initiating an api call. This is the entry point to add an emoji reaction anywhere in the app. - * - * If sending is allowed, the caller place can fake the emoji reaction locally thanks to [onAllowed]. - * If sending is not allowed, it will display the error directly to the user and avoid doing the api call. - */ - fun trySendEmojiReply(emoji: String, messageUid: String, reactions: Map, onAllowed: () -> Unit = {}) { - viewModelScope.launch { - when (val status = reactions.getEmojiSendStatus(emoji)) { - EmojiSendStatus.Allowed -> { - onAllowed() - sendEmojiReply(emoji, messageUid) - } - is EmojiSendStatus.NotAllowed -> snackbarManager.postValue(appContext.getString(status.errorMessageRes)) - } - } - } - - private fun Map.getEmojiSendStatus(emoji: String): EmojiSendStatus = when { - this[emoji]?.hasReacted == true -> EmojiSendStatus.NotAllowed.AlreadyUsed - hasAvailableReactionSlot().not() -> EmojiSendStatus.NotAllowed.MaxReactionReached - hasNetwork.not() -> EmojiSendStatus.NotAllowed.NoInternet - else -> EmojiSendStatus.Allowed - } - - /** - * The actual logic of sending an emoji reaction to the api. This method initializes a [Draft] instance, stores it into the - * database and schedules the [DraftsActionsWorker] so the draft is uploaded on the api. - */ - private suspend fun sendEmojiReply(emoji: String, messageUid: String) { - val targetMessage = messageController.getMessage(messageUid) ?: return - val (fullMessage, hasFailedFetching) = draftController.fetchHeavyDataIfNeeded(targetMessage) - if (hasFailedFetching) return - val draftMode = Draft.DraftMode.REPLY_ALL - - val draft = Draft().apply { - with(draftInitManager) { - setPreviousMessage(draftMode, fullMessage) - } - - val quote = draftInitManager.createQuote(draftMode, fullMessage, attachments) - body = EMOJI_REACTION_PLACEHOLDER + quote - - val currentMailbox = currentMailboxLive.asFlow().first() - with(draftInitManager) { - // We don't want to send the HTML code of the signature for an emoji reaction but we still need to send the - // identityId stored in a Signature - val signature = chooseSignature(currentMailbox.email, currentMailbox.signatures, draftMode, fullMessage) - setSignatureIdentity(signature) - } - - mimeType = Utils.TEXT_HTML - - action = Draft.DraftAction.SEND_REACTION - emojiReaction = emoji - } - - draftController.upsertDraft(draft) - - draftsActionsWorkerScheduler.scheduleWork(draft.localUuid, AccountUtils.currentMailboxId, AccountUtils.currentUserId) - } - - private sealed interface EmojiSendStatus { - data object Allowed : EmojiSendStatus - - sealed class NotAllowed(@StringRes val errorMessageRes: Int) : EmojiSendStatus { - data object AlreadyUsed : NotAllowed(ErrorCode.EmojiReactions.alreadyUsed.translateRes) - data object MaxReactionReached : NotAllowed(ErrorCode.EmojiReactions.maxReactionReached.translateRes) - data object NoInternet : NotAllowed(RCore.string.noConnection) - } - } - //endregion - - //region Undo action - fun undoAction(undoData: UndoData) = viewModelScope.launch(ioCoroutineContext) { - - fun List>.getFailedCall() = firstOrNull { it.data != true } - - val mailbox = currentMailbox.value!! - 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() - - val snackbarTitle = when { - failedCall == null -> R.string.snackbarMoveCancelled - else -> failedCall.translateError() - } - - snackbarManager.postValue(appContext.getString(snackbarTitle)) - } - //endregion - //region Manage Folder private suspend fun createNewFolderSync(name: String): String? { val mailbox = currentMailbox.value ?: return null @@ -1746,8 +1076,6 @@ class MainViewModel @Inject constructor( private val DEFAULT_SELECTED_FOLDER = FolderRole.INBOX private const val REFRESH_DELAY = 2_000L // We add this delay because `etop` isn't always big enough. private const val MAX_REFRESH_DELAY = 6_000L - - private const val EMOJI_REACTION_PLACEHOLDER = "
__REACTION_PLACEMENT__
" } } 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 ed1cdd9dd6..34bb9a036a 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 @@ -197,7 +197,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val twoPaneViewModel: TwoPaneViewModel by activityViewModels() private val threadViewModel: ThreadViewModel by viewModels() - private val actionsViewModel: ActionsViewModel by viewModels() + private val actionsViewModel: ActionsViewModel by activityViewModels() private val twoPaneFragment inline get() = parentFragment as TwoPaneFragment private val threadAdapter inline get() = binding.messagesList.adapter as ThreadAdapter @@ -378,7 +378,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { ) }, activateSpamFilter = { actionsViewModel.activateSpamFilter(mainViewModel.currentMailbox.value!!) }, - unblockMail = mainViewModel::unblockMail, + unblockMail = { actionsViewModel.unblockMail(it, mainViewModel.currentMailbox.value!!) }, replyToCalendarEvent = { attendanceState, message -> replyToCalendarEvent( attendanceState, 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 1748883ecb..9217ee9d1c 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 @@ -36,12 +36,14 @@ 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.draft.Draft 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 @@ -88,6 +90,7 @@ class ActionsViewModel @Inject constructor( 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 snackbarManager: SnackbarManager, @@ -96,17 +99,19 @@ class ActionsViewModel @Inject constructor( ) : AndroidViewModel(application) { private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) + val isDownloadingChanges: MutableLiveData = MutableLiveData(false) + val activityDialogLoaderResetTrigger = SingleLiveEvent() - val isMovedToNewFolder = SingleLiveEvent() val reportPhishingTrigger = SingleLiveEvent() //region Spam - fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) = + fun moveToSpamFolder(messagesUid: List, currentFolderId: String?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { val messages = messageController.getMessages(messagesUid) toggleMessagesSpamStatus(messages, currentFolderId, mailbox) } + } fun toggleThreadsOrMessagesSpamStatus( messages: List? = null, @@ -158,6 +163,29 @@ class ActionsViewModel @Inject constructor( return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } } + fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { + if (mailbox == null) return@launch + + with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { + if (isSuccess()) { + val updatedSendersRestrictions = data!!.apply { + blockedSenders.removeIf { it.email == email } + } + updateBlockedSenders(mailbox, updatedSendersRestrictions) + } + } + } + + 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 + } + } + } + } + //endregion //region Move @@ -917,6 +945,7 @@ class ActionsViewModel @Inject constructor( } companion object { + private val TAG: String = ActionsViewModel::class.java.simpleName private const val EMOJI_REACTION_PLACEHOLDER = "
__REACTION_PLACEMENT__
" } } From 1bbb47594242435170d79da9366733787ae32137 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Fri, 30 Jan 2026 16:12:59 +0100 Subject: [PATCH 05/13] refactor: Refactor code --- .../com/infomaniak/mail/ui/MainViewModel.kt | 4 +- .../main/folder/ThreadListMultiSelection.kt | 4 +- .../mail/ui/main/thread/ThreadFragment.kt | 28 +++---- .../main/thread/actions/ActionsViewModel.kt | 78 ++++++------------- .../MessageActionsBottomSheetDialog.kt | 6 +- .../actions/MultiSelectBottomSheetDialog.kt | 4 +- 6 files changed, 46 insertions(+), 78 deletions(-) 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 0e9de75e55..cd350dd05c 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -889,10 +889,10 @@ class MainViewModel @Inject constructor( fun moveToNewFolder( name: String, threadsUids: List, - messageUid: List?, + messagesUids: List?, ) = viewModelScope.launch(ioCoroutineContext) { val newFolderId = createNewFolderSync(name) ?: return@launch - moveThreadsOrMessageTo(newFolderId, threadsUids, messageUid) + moveThreadsOrMessageTo(newFolderId, threadsUids, messagesUids) isMovedToNewFolder.postValue(true) } //endregion 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 11a36a3663..86cad6bcbe 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 @@ -77,7 +77,7 @@ class ThreadListMultiSelection { R.id.quickActionUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, selectedThreadsCount) actionsViewModel.toggleThreadsOrMessagesSeenStatus( - selectedThreadsUids, + threadsUids = selectedThreadsUids, shouldRead = shouldMultiselectRead, currentFolderId = currentFolderId, mailbox = currentMailbox.value!! @@ -101,7 +101,7 @@ class ThreadListMultiSelection { R.id.quickActionFavorite -> { trackMultiSelectActionEvent(MatomoName.Favorite, selectedThreadsCount) actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - selectedThreadsUids, + threadsUids = selectedThreadsUids, mailbox = currentMailbox.value!!, shouldFavorite = shouldMultiselectFavorite ) 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 34bb9a036a..7db55f7b00 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 @@ -372,9 +372,9 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { onUnsubscribeClicked = threadViewModel::unsubscribeMessage, moveMessageToSpam = { messageUid -> actionsViewModel.moveToSpamFolder( - listOf(messageUid), - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + messagesUid = listOf(messageUid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) }, activateSpamFilter = { actionsViewModel.activateSpamFilter(mainViewModel.currentMailbox.value!!) }, @@ -793,10 +793,10 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private fun snoozeThreads(timestamp: Long, threadUids: List) { lifecycleScope.launch { val isSuccess = actionsViewModel.snoozeThreads( - Date(timestamp), - threadUids, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + date = Date(timestamp), + threadUids = threadUids, + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) if (isSuccess) twoPaneViewModel.closeThread() } @@ -807,9 +807,9 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { binding.snoozeAlert.showAction1Progress() val result = actionsViewModel.rescheduleSnoozedThreads( - Date(timestamp), - threadUids, - mainViewModel.currentMailbox.value!! + date = Date(timestamp), + threadUids = threadUids, + mailbox = mainViewModel.currentMailbox.value!! ) binding.snoozeAlert.hideAction1Progress(R.string.buttonModify) @@ -852,8 +852,9 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { R.id.quickActionArchive -> { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Archive, isFromArchive) + val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup actionsViewModel.archiveThreadsOrMessages( - threads = listOf(threadViewModel.threadLive.value!!), + threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! ) @@ -862,12 +863,13 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { R.id.quickActionDelete -> { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Delete) - // TODO: CHECK NULL + val thread = threadViewModel.threadLive.value ?: return@deleteWithConfirmationPopup actionsViewModel.deleteThreadsOrMessages( - threads = listOf(threadViewModel.threadLive.value!!), + threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, mailbox = mainViewModel.currentMailbox.value!! ) + } } R.id.quickActionMenu -> { 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 9217ee9d1c..e5a48a3d8f 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 @@ -196,7 +196,7 @@ class ActionsViewModel @Inject constructor( currentFolderId: String?, mailbox: Mailbox, ) = viewModelScope.launch(ioCoroutineContext) { - val destinationFolder = folderController.getFolder(destinationFolderId)!! + val destinationFolder = folderController.getFolder(destinationFolderId) ?: return@launch val threads: List? = threadsUids?.let { threadController.getThreads(threadsUids).toList() } val messages = messagesUid?.let { messageController.getMessages(it) } val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) @@ -285,7 +285,7 @@ class ActionsViewModel @Inject constructor( private fun showMoveSnackbar( threadsMoved: List, - messages: List, + messagesMoved: List, apiResponses: List>, destinationFolder: Folder, ) { @@ -294,12 +294,11 @@ class ActionsViewModel @Inject constructor( val snackbarTitle = when { apiResponses.allFailed() -> appContext.getString(apiResponses.first().translateError()) - threadsMoved.count() > 0 -> appContext.resources.getQuantityString( + threadsMoved.count() > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( R.plurals.snackbarThreadMoved, threadsMoved.count(), destination ) - //TODO: A MESSAGES MOVED QUANTITY STRING else -> appContext.getString(R.string.snackbarMessageMoved, destination) } @@ -308,7 +307,7 @@ class ActionsViewModel @Inject constructor( null } else { val undoDestinationId = destinationFolder.id - val foldersIds = messages.getFoldersIds(exception = undoDestinationId) + val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) foldersIds += destinationFolder.id UndoData( resources = apiResponses.mapNotNull { it.data?.undoResource }, @@ -321,17 +320,13 @@ class ActionsViewModel @Inject constructor( } //endregion - 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) - } + 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 @@ -343,27 +338,20 @@ class ActionsViewModel @Inject constructor( messages: List? = null, currentFolder: Folder?, mailbox: Mailbox - ) = - viewModelScope.launch(ioCoroutineContext) { - val messagesToDelete = getMessagesToDelete(threads, messages) - deleteMessages(messagesToDelete, currentFolder, mailbox) - } + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToDelete = getMessagesToDelete(threads, messages) + deleteMessages(messagesToDelete, currentFolder, mailbox) + } private fun deleteMessages( messages: List, currentFolder: Folder?, mailbox: Mailbox ) = viewModelScope.launch(ioCoroutineContext) { - - val shouldPermanentlyDelete = - isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messages, currentFolder)) + val shouldPermanentlyDelete = isPermanentDeleteFolder(folderRoleUtils.getActionFolderRole(messages, currentFolder)) if (shouldPermanentlyDelete) { - permanentlyDelete( - messagesToDelete = messages, - currentFolder = currentFolder, - mailbox = mailbox - ) + permanentlyDelete(messages, currentFolder, mailbox) } else { moveMessagesTo( destinationFolder = folderController.getFolder(FolderRole.TRASH)!!, @@ -374,11 +362,7 @@ class ActionsViewModel @Inject constructor( } } - private suspend fun permanentlyDelete( - messagesToDelete: List, - currentFolder: Folder?, - mailbox: Mailbox - ) { + private suspend fun permanentlyDelete(messagesToDelete: List, currentFolder: Folder?, mailbox: Mailbox) { val undoResources = emptyList() val uids = messagesToDelete.getUids() @@ -393,7 +377,6 @@ class ActionsViewModel @Inject constructor( activityDialogLoaderResetTrigger.postValue(Unit) if (apiResponses.atLeastOneSucceeded()) { - refreshFoldersAsync( mailbox = mailbox, messagesFoldersIds = messagesToDelete.getFoldersIds(), @@ -403,7 +386,7 @@ class ActionsViewModel @Inject constructor( } if (apiResponses.atLeastOneFailed()) threadController.updateIsLocallyMovedOutStatus( - uidsToMove, + threadsUids = uidsToMove, hasBeenMovedOut = false ) @@ -415,7 +398,6 @@ class ActionsViewModel @Inject constructor( undoResources = undoResources, undoFoldersIds = undoFoldersIds, undoDestinationId = undoDestinationId, - // TODO: calculate threads? numberOfImpactedThreads = messagesToDelete.count(), ) } @@ -430,7 +412,6 @@ class ActionsViewModel @Inject constructor( ) { val snackbarTitle = if (apiResponses.atLeastOneSucceeded()) { if (messages.count() > 1) { - // TODO: Do a string for multiple messages deleted appContext.resources.getQuantityString( R.plurals.snackbarThreadDeletedPermanently, numberOfImpactedThreads @@ -475,9 +456,8 @@ class ActionsViewModel @Inject constructor( 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)!! + val destinationFolder = folderController.getFolder(destinationFolderRole) ?: return@launch moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) } @@ -559,7 +539,6 @@ class ActionsViewModel @Inject constructor( mailbox: Mailbox ): List = when { threads != null -> threads.flatMap { thread -> - //TODO: CHECK IF FEATURE FLAG IS OK LIKE THIS messageController.getLastMessageAndItsDuplicatesToExecuteAction(thread, mailbox.featureFlags) } messages != null -> messageController.getMessagesAndDuplicates(messages) @@ -643,11 +622,7 @@ class ActionsViewModel @Inject constructor( //endregion //region Phishing - fun reportPhishing( - messages: List, - currentFolder: Folder?, - mailbox: Mailbox - ) { + fun reportPhishing(messages: List, currentFolder: Folder?, mailbox: Mailbox) { viewModelScope.launch(ioCoroutineContext) { val mailboxUuid = mailbox.uuid val messagesUids: List = messages.map { it.uid } @@ -662,14 +637,13 @@ class ActionsViewModel @Inject constructor( if (folderRoleUtils.getActionFolderRole(messages, currentFolder) != FolderRole.SPAM) { toggleThreadsOrMessagesSpamStatus( - messages, + messages = messages, currentFolderId = currentFolder?.id, mailbox = mailbox, displaySnackbar = false ) } - R.string.snackbarReportPhishingConfirmation } else { translateError() @@ -686,7 +660,6 @@ class ActionsViewModel @Inject constructor( 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)) @@ -697,12 +670,7 @@ 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 { + suspend fun snoozeThreads(date: Date, threadUids: List, currentFolderId: String?, mailbox: Mailbox?): Boolean { var isSuccess = false viewModelScope.launch { @@ -825,7 +793,7 @@ class ActionsViewModel @Inject constructor( } //endregion -//region Emoji reaction + //region Emoji reaction /** * Wrapper method to send an emoji reaction to the api. This method will check if the emoji reaction is allowed before * initiating an api call. This is the entry point to add an emoji reaction anywhere in the app. 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 acfa70f590..3041b03057 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 @@ -21,6 +21,7 @@ import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.infomaniak.core.common.extensions.isNightModeEnabled @@ -52,8 +53,6 @@ import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDi import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.common.R as RCore @@ -86,8 +85,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) = with(navigationArgs) { super.onViewCreated(view, savedInstanceState) binding.print.isVisible = true - // Use a scope not tied to the DialogFragment's view lifecycle so it keeps running after the sheet is closed. - globalCoroutineScope.launch(Dispatchers.Main.immediate, start = CoroutineStart.UNDISPATCHED) { + viewLifecycleOwner.lifecycleScope.launch { // Initialization of threadsUids to populate junkMessages and potentialUsersToBlock junkMessagesViewModel.threadsUids = listOf(threadUid) 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 32c7a201a4..353d3c1b64 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 @@ -195,7 +195,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - threadsUids, + threadsUids = threadsUids, mailbox = currentMailbox.value!!, shouldFavorite = shouldFavorite ) @@ -235,7 +235,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { R.id.actionReadUnread -> { trackMultiSelectActionEvent(MatomoName.MarkAsSeen, threadsCount, isFromBottomSheet = true) actionsViewModel.toggleThreadsOrMessagesSeenStatus( - threadsUids, + threadsUids = threadsUids, shouldRead = shouldRead, currentFolderId = currentFolderId, mailbox = currentMailbox From c5ebecc385efc0cf42aa29fc773dd30f10666663 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 2 Feb 2026 08:46:09 +0100 Subject: [PATCH 06/13] fix: Permanently delete trigger and multiselect phishing --- app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt | 2 +- app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt | 2 -- .../ui/main/thread/actions/MultiSelectBottomSheetDialog.kt | 4 +++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt index d3df913e31..22385327f0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainActivity.kt @@ -271,7 +271,7 @@ class MainActivity : BaseActivity() { } private fun observeActivityDialogLoaderReset() { - mainViewModel.activityDialogLoaderResetTrigger.observe(this) { descriptionDialog.resetLoadingAndDismiss() } + actionsViewModel.activityDialogLoaderResetTrigger.observe(this) { descriptionDialog.resetLoadingAndDismiss() } } private fun observeDraftWorkerResults() { 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 cd350dd05c..776ff226d0 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt @@ -159,12 +159,10 @@ class MainViewModel @Inject constructor( val isMovedToNewFolder = SingleLiveEvent() val toggleLightThemeForMessage = SingleLiveEvent() val deletedMessages = SingleLiveEvent>() - val activityDialogLoaderResetTrigger = SingleLiveEvent() val flushFolderTrigger = SingleLiveEvent() val newFolderResultTrigger = MutableLiveData() val renameFolderResultTrigger = MutableLiveData() val deleteFolderResultTrigger = MutableLiveData() - val reportPhishingTrigger = SingleLiveEvent() val reportDisplayProblemTrigger = SingleLiveEvent() val canInstallUpdate = MutableLiveData(false) 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 353d3c1b64..7b527a89e8 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 @@ -168,6 +168,8 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { ) }, ) + + isMultiSelectOn = false } binding.blockSender.setClosingOnClickListener { @@ -189,7 +191,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - mainViewModel.isMultiSelectOn = false + isMultiSelectOn = false } binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { From 92cf5768fa4b5c6eaf33f48d91e3fca51341824f Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 2 Feb 2026 10:48:50 +0100 Subject: [PATCH 07/13] refactor: Remove unnused variables, add parameters name --- .../main/folderPicker/FolderPickerFragment.kt | 10 ++-- .../main/thread/actions/ActionsViewModel.kt | 47 ++++++++----------- .../MessageActionsBottomSheetDialog.kt | 4 -- 3 files changed, 24 insertions(+), 37 deletions(-) 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 c8867cd753..268c402963 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 @@ -154,11 +154,11 @@ class FolderPickerFragment : Fragment() { when (action) { FolderPickerAction.MOVE -> folder?.id?.let { actionsViewModel.moveThreadsOrMessagesTo( - it, - threadsUids?.let { threadsUids.toList() }, - messagesUids?.let { messagesUids.toList() }, - mainViewModel.currentFolderId, - mainViewModel.currentMailbox.value!! + destinationFolderId = it, + threadsUids = threadsUids.let { threadsUids.toList() }, + messagesUid = messagesUids?.let { messagesUids.toList() }, + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! ) } FolderPickerAction.SEARCH -> { 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 e5a48a3d8f..35043dc80b 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 @@ -512,10 +512,7 @@ class ActionsViewModel @Inject constructor( } } - private suspend fun markAsUnseen( - messages: List, - mailbox: Mailbox - ) { + private suspend fun markAsUnseen(messages: List, mailbox: Mailbox) { val messagesUids = messages.map { it.uid } sharedUtils.updateSeenStatus(messagesUids, isSeen = false) @@ -544,11 +541,9 @@ class ActionsViewModel @Inject constructor( 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, @@ -572,30 +567,27 @@ class ActionsViewModel @Inject constructor( toggleMessagesFavoriteStatus(messages, isFavorite, mailbox) } - private fun toggleMessagesFavoriteStatus( - messages: List, - isFavorite: Boolean, - mailbox: Mailbox - ) = viewModelScope.launch(ioCoroutineContext) { - - val uids = messages.getUids() + private fun toggleMessagesFavoriteStatus(messages: List, isFavorite: Boolean, mailbox: Mailbox) { + viewModelScope.launch(ioCoroutineContext) { + val uids = messages.getUids() - updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) + updateFavoriteStatus(messagesUids = uids, isFavorite = !isFavorite) - val apiResponses = if (isFavorite) { - ApiRepository.removeFromFavorites(mailbox.uuid, uids) - } else { - ApiRepository.addToFavorites(mailbox.uuid, uids) - } + 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) + if (apiResponses.atLeastOneSucceeded()) { + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(), + callbacks = RefreshCallbacks(::onDownloadStart, ::onDownloadStop), + ) + } else { + updateFavoriteStatus(messagesUids = uids, isFavorite = isFavorite) + } } } @@ -618,7 +610,6 @@ class ActionsViewModel @Inject constructor( MessageController.updateFavoriteStatus(messagesUids, isFavorite, realm = this) } } - //endregion //region Phishing 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 3041b03057..c10b2c193b 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 @@ -52,7 +52,6 @@ import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.navigateToDownloadMessagesProgressDialog import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject import com.infomaniak.core.common.R as RCore @@ -73,9 +72,6 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { @Inject lateinit var descriptionDialog: DescriptionAlertDialog - @Inject - lateinit var globalCoroutineScope: CoroutineScope - @Inject lateinit var folderRoleUtils: FolderRoleUtils From 96172ac65f970e3c9978ed0c1fd4fba4baa3b5b5 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 2 Feb 2026 11:13:41 +0100 Subject: [PATCH 08/13] refactor: Fix sonar issues --- .../main/folder/PerformSwipeActionManager.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) 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 c282479a71..a90ddaa530 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 @@ -30,9 +30,11 @@ import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter +import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType +import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.extensions.animatedNavigation import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup @@ -72,11 +74,7 @@ object PerformSwipeActionManager { } private fun ThreadListFragment.performSwipeAction( - swipeAction: SwipeAction, - folderRole: FolderRole?, - thread: Thread, - position: Int, - isPermanentDeleteFolder: Boolean + swipeAction: SwipeAction, folderRole: FolderRole?, thread: Thread, position: Int, isPermanentDeleteFolder: Boolean ) = when (swipeAction) { SwipeAction.TUTORIAL -> { localSettings.setDefaultSwipeActions() @@ -97,11 +95,7 @@ object PerformSwipeActionManager { } }, ) { - actionsViewModel.archiveThreadsOrMessages( - threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, - mailbox = mainViewModel.currentMailbox.value!! - ) + handleArchive(thread, actionsViewModel, mainViewModel) } } SwipeAction.DELETE -> { @@ -118,18 +112,13 @@ object PerformSwipeActionManager { }, callback = { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - actionsViewModel.deleteThreadsOrMessages( - threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, - mailbox = mainViewModel.currentMailbox.value!! - ) + handleDelete(thread, actionsViewModel, mainViewModel) }, ) } SwipeAction.FAVORITE -> { actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - threadsUids = listOf(thread.uid), - mailbox = mainViewModel.currentMailbox.value!! + threadsUids = listOf(thread.uid), mailbox = mainViewModel.currentMailbox.value!! ) true } @@ -183,6 +172,22 @@ object PerformSwipeActionManager { SwipeAction.NONE -> error("Cannot swipe on an action which is not set") } + private fun handleArchive(thread: Thread, actionsViewModel: ActionsViewModel, mainViewModel: MainViewModel) { + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) + } + + private fun handleDelete(thread: Thread, actionsViewModel: ActionsViewModel, mainViewModel: MainViewModel) { + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) + } + private fun LocalSettings.setDefaultSwipeActions() { if (swipeRight == SwipeAction.TUTORIAL) swipeRight = SwipeActionsSettingsFragment.DEFAULT_SWIPE_ACTION_RIGHT if (swipeLeft == SwipeAction.TUTORIAL) swipeLeft = SwipeActionsSettingsFragment.DEFAULT_SWIPE_ACTION_LEFT From c9c7432e8befa6bffc9ddeebeb4fb701414f210f Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 17 Feb 2026 10:41:27 +0100 Subject: [PATCH 09/13] fix: Fix PR comments --- .../cache/mailboxContent/MessageController.kt | 1 + .../main/folder/PerformSwipeActionManager.kt | 245 ++++++++++-------- .../main/folderPicker/FolderPickerFragment.kt | 4 +- .../main/thread/actions/ActionsViewModel.kt | 155 +++++------ .../actions/MultiSelectBottomSheetDialog.kt | 62 +++-- 5 files changed, 268 insertions(+), 199 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt index c6a9911183..4b78086a67 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt @@ -141,6 +141,7 @@ class MessageController @Inject constructor( suspend fun getMessagesAndDuplicates(messages: List): List { return messages.flatMap { message -> + if (message.threads.isEmpty()) return listOf(message) getMessageAndDuplicates(message.threads.first(), message) } } 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 a90ddaa530..59a99d3650 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 @@ -30,16 +30,15 @@ import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter -import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction import com.infomaniak.mail.ui.main.settings.appearance.swipe.SwipeActionsSettingsFragment import com.infomaniak.mail.ui.main.thread.ThreadViewModel.SnoozeScheduleType -import com.infomaniak.mail.ui.main.thread.actions.ActionsViewModel import com.infomaniak.mail.utils.extensions.animatedNavigation import com.infomaniak.mail.utils.extensions.archiveWithConfirmationPopup import com.infomaniak.mail.utils.extensions.deleteWithConfirmationPopup import com.infomaniak.mail.utils.extensions.getAnimatedNavOptions import com.infomaniak.mail.utils.extensions.moveWithConfirmationPopup +import com.infomaniak.core.common.R as RCore object PerformSwipeActionManager { @@ -74,117 +73,161 @@ object PerformSwipeActionManager { } private fun ThreadListFragment.performSwipeAction( - swipeAction: SwipeAction, folderRole: FolderRole?, thread: Thread, position: Int, isPermanentDeleteFolder: Boolean - ) = when (swipeAction) { - SwipeAction.TUTORIAL -> { - localSettings.setDefaultSwipeActions() - safelyNavigate(ThreadListFragmentDirections.actionThreadListFragmentToSettingsFragment()) - findNavController().navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) - true - } - SwipeAction.ARCHIVE -> { - descriptionDialog.archiveWithConfirmationPopup( - folderRole = folderRole, - count = 1, - displayLoader = false, - onCancel = { - // Notify only if the user cancelled the popup (e.g. the thread is not deleted), - // otherwise it will notify the next item in the list and make it slightly blink - if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - threadListAdapter.notifyItemChanged(position) - } - }, - ) { - handleArchive(thread, actionsViewModel, mainViewModel) + swipeAction: SwipeAction, + folderRole: FolderRole?, + thread: Thread, + position: Int, + isPermanentDeleteFolder: Boolean + ): Boolean { + return when (swipeAction) { + SwipeAction.TUTORIAL -> { + localSettings.setDefaultSwipeActions() + safelyNavigate(ThreadListFragmentDirections.actionThreadListFragmentToSettingsFragment()) + findNavController().navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) + true } - } - SwipeAction.DELETE -> { - descriptionDialog.deleteWithConfirmationPopup( - folderRole = folderRole, - count = 1, - displayLoader = false, - onCancel = { - // Notify only if the user cancelled the popup (e.g. the thread is not deleted), - // otherwise it will notify the next item in the list and make it slightly blink - if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { - threadListAdapter.notifyItemChanged(position) - } - }, - callback = { - if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - handleDelete(thread, actionsViewModel, mainViewModel) - }, - ) - } - SwipeAction.FAVORITE -> { - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - threadsUids = listOf(thread.uid), mailbox = mainViewModel.currentMailbox.value!! - ) - true - } - SwipeAction.MOVE -> { - val navController = findNavController() - descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { - navController.animatedNavigation( - directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( - threadsUids = arrayOf(thread.uid), - action = FolderPickerAction.MOVE, - sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID - ), + SwipeAction.ARCHIVE -> { + handleArchiveSwipe(thread, position, folderRole) + } + SwipeAction.DELETE -> { + handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder) + } + SwipeAction.FAVORITE -> { + val currentMailbox = mainViewModel.currentMailbox.value + if (currentMailbox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + return true + } + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) + true + } + SwipeAction.MOVE -> { + val navController = findNavController() + descriptionDialog.moveWithConfirmationPopup(folderRole, count = 1) { + navController.animatedNavigation( + directions = ThreadListFragmentDirections.actionThreadListFragmentToFolderPickerFragment( + threadsUids = arrayOf(thread.uid), + action = FolderPickerAction.MOVE, + sourceFolderId = mainViewModel.currentFolderId ?: Folder.DUMMY_FOLDER_ID + ), + ) + } + true + } + SwipeAction.QUICKACTIONS_MENU -> { + safelyNavigate( + ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( + threadUid = thread.uid, + shouldLoadDistantResources = false, + ) ) + true } - true - } - SwipeAction.QUICKACTIONS_MENU -> { - safelyNavigate( - ThreadListFragmentDirections.actionThreadListFragmentToThreadActionsBottomSheetDialog( - threadUid = thread.uid, - shouldLoadDistantResources = false, + SwipeAction.READ_UNREAD -> { + val currentMailbox = mainViewModel.currentMailbox.value + if (currentMailbox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + return true + } + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + threadsUids = listOf(thread.uid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox ) - ) - true - } - SwipeAction.READ_UNREAD -> { - actionsViewModel.toggleThreadsOrMessagesSeenStatus( - threadsUids = listOf(thread.uid), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) - mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + true + } + + SwipeAction.SPAM -> { + val currentMailbox = mainViewModel.currentMailbox.value + if (currentMailbox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + return true + } + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = setOf(thread), + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox + ) + false + } + SwipeAction.SNOOZE -> { + val snoozeScheduleType = if (thread.isSnoozed()) { + SnoozeScheduleType.Modify(thread.uid) + } else { + SnoozeScheduleType.Snooze(thread.uid) + } + navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) + true + } + SwipeAction.NONE -> error("Cannot swipe on an action which is not set") } - SwipeAction.SPAM -> { - actionsViewModel.toggleThreadsOrMessagesSpamStatus( - threads = setOf(thread), - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!! - ) - false + } + + private fun ThreadListFragment.handleArchiveSwipe(thread: Thread, position: Int, folderRole: FolderRole?): Boolean { + fun onCancel() { + if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + threadListAdapter.notifyItemChanged(position) + } } - SwipeAction.SNOOZE -> { - val snoozeScheduleType = if (thread.isSnoozed()) { - SnoozeScheduleType.Modify(thread.uid) - } else { - SnoozeScheduleType.Snooze(thread.uid) + + fun onSuccess() { + val currentMailBox = mainViewModel.currentMailbox.value + if (currentMailBox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + return } - navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) - true + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailBox + ) } - SwipeAction.NONE -> error("Cannot swipe on an action which is not set") - } - private fun handleArchive(thread: Thread, actionsViewModel: ActionsViewModel, mainViewModel: MainViewModel) { - actionsViewModel.archiveThreadsOrMessages( - threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, - mailbox = mainViewModel.currentMailbox.value!! + return descriptionDialog.archiveWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = ::onCancel, + onPositiveButtonClicked = ::onSuccess ) } - private fun handleDelete(thread: Thread, actionsViewModel: ActionsViewModel, mainViewModel: MainViewModel) { - actionsViewModel.deleteThreadsOrMessages( - threads = listOf(thread), - currentFolder = mainViewModel.currentFolder.value, - mailbox = mainViewModel.currentMailbox.value!! + private fun ThreadListFragment.handleDeleteSwipe( + thread: Thread, + position: Int, + folderRole: FolderRole?, + isPermanentDeleteFolder: Boolean + ): Boolean { + fun onCancel() { + // Notify only if the user cancelled the popup (e.g. the thread is not deleted), + // otherwise it will notify the next item in the list and make it slightly blink + if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { + threadListAdapter.notifyItemChanged(position) + } + } + + fun onHandleDelete() { + if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) + val currentMailBox = mainViewModel.currentMailbox.value + if (currentMailBox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + return + } + + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailBox + ) + } + + return descriptionDialog.deleteWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = ::onCancel, + callback = ::onHandleDelete, ) } 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 268c402963..50c997ef5f 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 @@ -155,8 +155,8 @@ class FolderPickerFragment : Fragment() { FolderPickerAction.MOVE -> folder?.id?.let { actionsViewModel.moveThreadsOrMessagesTo( destinationFolderId = it, - threadsUids = threadsUids.let { threadsUids.toList() }, - messagesUid = messagesUids?.let { messagesUids.toList() }, + threadsUids = threadsUids.toList(), + messagesUids = messagesUids?.toList(), currentFolderId = mainViewModel.currentFolderId, mailbox = mainViewModel.currentMailbox.value!! ) 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 35043dc80b..e2ba8a6297 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 @@ -129,6 +129,32 @@ class ActionsViewModel @Inject constructor( toggleMessagesSpamStatus(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) } + fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + ApiRepository.setSpamFilter( + mailboxHostingId = mailbox.hostingId, + mailboxName = mailbox.mailboxName, + activateSpamFilter = true, + ) + } + + 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.apply { + blockedSenders.removeIf { it.email == email } + } + updateBlockedSenders(mailbox, restrictions) + } + } + } + private fun toggleMessagesSpamStatus( messages: List, currentFolderId: String?, @@ -146,36 +172,15 @@ class ActionsViewModel @Inject constructor( } val destinationFolder = folderController.getFolder(destinationFolderRole)!! - val messages = messageController.getUnscheduledMessages(messages) + val unscheduleMessages = messageController.getUnscheduledMessages(messages) - moveMessagesTo(destinationFolder, currentFolderId, mailbox, messages, displaySnackbar) - } - - fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { - ApiRepository.setSpamFilter( - mailboxHostingId = mailbox.hostingId, - mailboxName = mailbox.mailboxName, - activateSpamFilter = true, - ) + moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, displaySnackbar) } private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } } - fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { - if (mailbox == null) return@launch - - with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { - if (isSuccess()) { - val updatedSendersRestrictions = data!!.apply { - blockedSenders.removeIf { it.email == email } - } - updateBlockedSenders(mailbox, updatedSendersRestrictions) - } - } - } - private suspend fun updateBlockedSenders(mailbox: Mailbox, updatedSendersRestrictions: SendersRestrictions) { with(ApiRepository.updateBlockedSenders(mailbox.hostingId, mailbox.mailboxName, updatedSendersRestrictions)) { if (isSuccess()) { @@ -185,20 +190,19 @@ class ActionsViewModel @Inject constructor( } } } - //endregion //region Move fun moveThreadsOrMessagesTo( destinationFolderId: String, threadsUids: List? = null, - messagesUid: List? = null, + messagesUids: List? = null, 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 = messagesUid?.let { messageController.getMessages(it) } + val messages = messagesUids?.let { messageController.getMessages(it) } val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) moveMessagesTo(destinationFolder, currentFolderId, mailbox, messagesToMove) @@ -320,18 +324,6 @@ class ActionsViewModel @Inject constructor( } //endregion - 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 - } - //region Delete fun deleteThreadsOrMessages( threads: List? = null, @@ -429,7 +421,12 @@ class ActionsViewModel @Inject constructor( } private suspend fun getMessagesToDelete(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } + threads != null -> threads.flatMap { + messageController.getUnscheduledMessagesFromThread( + it, + includeDuplicates = true + ) + } messages != null -> messageController.getMessagesAndDuplicates(messages) else -> emptyList() } @@ -462,18 +459,7 @@ class ActionsViewModel @Inject constructor( moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) } - 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) - } - //region Seen - fun toggleThreadsOrMessagesSeenStatus( threadsUids: List? = null, messages: List? = null, @@ -725,28 +711,6 @@ class ActionsViewModel @Inject constructor( return rescheduleResult } - 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)), - ) - } - - private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { - val errorMessageRes = when (errorResult) { - BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedModify - is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError - BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred - } - return appContext.getString(errorMessageRes) - } - suspend fun unsnoozeThreads(threads: Collection, mailbox: Mailbox?): BatchSnoozeResult { var unsnoozeResult: BatchSnoozeResult = BatchSnoozeResult.Error.Unknown @@ -773,6 +737,28 @@ class ActionsViewModel @Inject constructor( 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)), + ) + } + + private fun getRescheduleSnoozedErrorMessage(errorResult: BatchSnoozeResult.Error): String { + val errorMessageRes = when (errorResult) { + BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedModify + is BatchSnoozeResult.Error.ApiError -> errorResult.translatedError + BatchSnoozeResult.Error.Unknown -> RCore.string.anErrorHasOccurred + } + return appContext.getString(errorMessageRes) + } + private fun getUnsnoozeErrorMessage(errorResult: BatchSnoozeResult.Error): String { val errorMessageRes = when (errorResult) { BatchSnoozeResult.Error.NoneSucceeded -> R.string.errorSnoozeFailedCancel @@ -826,8 +812,8 @@ class ActionsViewModel @Inject constructor( val targetMessage = messageController.getMessage(messageUid) ?: return val (fullMessage, hasFailedFetching) = draftController.fetchHeavyDataIfNeeded(targetMessage) if (hasFailedFetching) return - val draftMode = Draft.DraftMode.REPLY_ALL + val draftMode = Draft.DraftMode.REPLY_ALL val draft = Draft().apply { with(draftInitManager) { setPreviousMessage(draftMode, fullMessage) @@ -903,8 +889,29 @@ class ActionsViewModel @Inject constructor( 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 + } + companion object { - private val TAG: String = ActionsViewModel::class.java.simpleName private const val EMOJI_REACTION_PLACEHOLDER = "
__REACTION_PLACEMENT__
" } } 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 7b527a89e8..c2372a1fc4 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 @@ -102,6 +102,12 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val threads = selectedThreads.toSet() val threadsUids = threads.map { it.uid } val threadsCount = threadsUids.count() + val currentMailbox = mainViewModel.currentMailbox.value + if (currentMailbox == null) { + SentryLog.e(TAG, "Mailbox is null but shouldn't") { scope -> + scope.setTag("context", "$TAG.onViewCreated") + } + } // Initialization of threadsUids to populate junkMessages and potentialUsersToBlock junkMessagesViewModel.threadsUids = threadsUids @@ -139,11 +145,15 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesSpamStatus( - threads = threads, - currentFolderId = mainViewModel.currentFolderId, - mailbox = mainViewModel.currentMailbox.value!!, - ) + if (currentMailbox == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + } else { + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = threads, + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox, + ) + } isMultiSelectOn = false } @@ -157,18 +167,22 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { SentryLog.e(TAG, getString(R.string.sentryErrorPhishingMessagesEmpty)) } - descriptionDialog.show( - title = getString(R.string.reportPhishingTitle), - description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), - onPositiveButtonClicked = { - actionsViewModel.reportPhishing( - messages = messages, - currentFolder = mainViewModel.currentFolder.value, - mailbox = mainViewModel.currentMailbox.value!! - ) - }, - ) - + if (currentMailbox == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + } else { + descriptionDialog.show( + title = getString(R.string.reportPhishingTitle), + description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = messages, + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailbox + ) + }, + ) + } + isMultiSelectOn = false } @@ -196,11 +210,15 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - threadsUids = threadsUids, - mailbox = currentMailbox.value!!, - shouldFavorite = shouldFavorite - ) + if (currentMailbox == null) { + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + } else { + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = threadsUids, + mailbox = currentMailbox, + shouldFavorite = shouldFavorite + ) + } isMultiSelectOn = false } From d1520b6fdad8fb73cd2665c2d843ba607049213c Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 17 Feb 2026 11:07:54 +0100 Subject: [PATCH 10/13] refactor: Move mailbox initialization and add not translatable error string --- .../main/folder/PerformSwipeActionManager.kt | 51 ++++++++----------- .../actions/MultiSelectBottomSheetDialog.kt | 4 +- .../res/values/non_translatable_strings.xml | 1 + 3 files changed, 24 insertions(+), 32 deletions(-) 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 59a99d3650..6303dbe875 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 @@ -20,6 +20,7 @@ package com.infomaniak.mail.ui.main.folder import androidx.navigation.fragment.findNavController import com.infomaniak.core.fragmentnavigation.safelyNavigate import com.infomaniak.core.matomo.Matomo.TrackerAction +import com.infomaniak.core.sentry.SentryLog import com.infomaniak.mail.MatomoMail.MatomoCategory import com.infomaniak.mail.MatomoMail.trackEvent import com.infomaniak.mail.R @@ -28,6 +29,7 @@ import com.infomaniak.mail.data.models.Folder import com.infomaniak.mail.data.models.Folder.FolderRole import com.infomaniak.mail.data.models.SwipeAction import com.infomaniak.mail.data.models.isSnoozed +import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.data.models.thread.Thread import com.infomaniak.mail.data.models.thread.Thread.ThreadFilter import com.infomaniak.mail.ui.main.folderPicker.FolderPickerAction @@ -79,6 +81,15 @@ object PerformSwipeActionManager { position: Int, isPermanentDeleteFolder: Boolean ): Boolean { + val currentMailbox = mainViewModel.currentMailbox.value + if (currentMailbox == null) { + snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) + SentryLog.e("PerformSwipeActionManager", getString(R.string.sentryErrorMailboxIsNull)) { scope -> + scope.setTag("context", "PerformSwipeActionManager.performSwipeAction") + } + return true + } + return when (swipeAction) { SwipeAction.TUTORIAL -> { localSettings.setDefaultSwipeActions() @@ -87,17 +98,12 @@ object PerformSwipeActionManager { true } SwipeAction.ARCHIVE -> { - handleArchiveSwipe(thread, position, folderRole) + handleArchiveSwipe(thread, position, folderRole, currentMailbox) } SwipeAction.DELETE -> { - handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder) + handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder, currentMailbox) } SwipeAction.FAVORITE -> { - val currentMailbox = mainViewModel.currentMailbox.value - if (currentMailbox == null) { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - return true - } actionsViewModel.toggleThreadsOrMessagesFavoriteStatus(threadsUids = listOf(thread.uid), mailbox = currentMailbox) true } @@ -124,11 +130,6 @@ object PerformSwipeActionManager { true } SwipeAction.READ_UNREAD -> { - val currentMailbox = mainViewModel.currentMailbox.value - if (currentMailbox == null) { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - return true - } actionsViewModel.toggleThreadsOrMessagesSeenStatus( threadsUids = listOf(thread.uid), currentFolderId = mainViewModel.currentFolderId, @@ -139,11 +140,6 @@ object PerformSwipeActionManager { } SwipeAction.SPAM -> { - val currentMailbox = mainViewModel.currentMailbox.value - if (currentMailbox == null) { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - return true - } actionsViewModel.toggleThreadsOrMessagesSpamStatus( threads = setOf(thread), currentFolderId = mainViewModel.currentFolderId, @@ -164,7 +160,12 @@ object PerformSwipeActionManager { } } - private fun ThreadListFragment.handleArchiveSwipe(thread: Thread, position: Int, folderRole: FolderRole?): Boolean { + private fun ThreadListFragment.handleArchiveSwipe( + thread: Thread, + position: Int, + folderRole: FolderRole?, + currentMailBox: Mailbox + ): Boolean { fun onCancel() { if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { threadListAdapter.notifyItemChanged(position) @@ -172,11 +173,6 @@ object PerformSwipeActionManager { } fun onSuccess() { - val currentMailBox = mainViewModel.currentMailbox.value - if (currentMailBox == null) { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - return - } actionsViewModel.archiveThreadsOrMessages( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.value, @@ -197,7 +193,8 @@ object PerformSwipeActionManager { thread: Thread, position: Int, folderRole: FolderRole?, - isPermanentDeleteFolder: Boolean + isPermanentDeleteFolder: Boolean, + currentMailBox: Mailbox ): Boolean { fun onCancel() { // Notify only if the user cancelled the popup (e.g. the thread is not deleted), @@ -209,12 +206,6 @@ object PerformSwipeActionManager { fun onHandleDelete() { if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) - val currentMailBox = mainViewModel.currentMailbox.value - if (currentMailBox == null) { - snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) - return - } - actionsViewModel.deleteThreadsOrMessages( threads = listOf(thread), currentFolder = mainViewModel.currentFolder.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 c2372a1fc4..93e5d1dfbb 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 @@ -104,7 +104,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val threadsCount = threadsUids.count() val currentMailbox = mainViewModel.currentMailbox.value if (currentMailbox == null) { - SentryLog.e(TAG, "Mailbox is null but shouldn't") { scope -> + SentryLog.e(TAG, getString(R.string.sentryErrorMailboxIsNull)) { scope -> scope.setTag("context", "$TAG.onViewCreated") } } @@ -240,7 +240,7 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { ) { binding.mainActions.setClosingOnClickListener(shouldCloseMultiSelection = true) { id: Int -> val currentMailbox = mainViewModel.currentMailbox.value ?: run { - SentryLog.e(TAG, "Mailbox is null but shouldn't") { scope -> + SentryLog.e(TAG, getString(R.string.sentryErrorMailboxIsNull)) { scope -> scope.setTag("context", "$TAG.setupMailAction") } return@setClosingOnClickListener diff --git a/app/src/main/res/values/non_translatable_strings.xml b/app/src/main/res/values/non_translatable_strings.xml index d7b8caa4da..44a0684f8f 100644 --- a/app/src/main/res/values/non_translatable_strings.xml +++ b/app/src/main/res/values/non_translatable_strings.xml @@ -19,4 +19,5 @@ RequestCurrentUser returned null while there are account in DB Potential users to block is null Phishing messages report failed because the list is empty + Mailbox is null From 5cfb0c9eb24395f9118ddcba15fc20ff1f006580 Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Tue, 17 Feb 2026 11:22:22 +0100 Subject: [PATCH 11/13] refactor: Refactor current mailbox initialization --- .../main/folder/PerformSwipeActionManager.kt | 3 +- .../main/thread/actions/ActionsViewModel.kt | 7 +-- .../actions/MultiSelectBottomSheetDialog.kt | 60 ++++++++----------- 3 files changed, 27 insertions(+), 43 deletions(-) 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 6303dbe875..4a01894039 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 @@ -81,8 +81,7 @@ object PerformSwipeActionManager { position: Int, isPermanentDeleteFolder: Boolean ): Boolean { - val currentMailbox = mainViewModel.currentMailbox.value - if (currentMailbox == null) { + val currentMailbox = mainViewModel.currentMailbox.value ?: run { snackbarManager.setValue(getString(RCore.string.anErrorHasOccurred)) SentryLog.e("PerformSwipeActionManager", getString(R.string.sentryErrorMailboxIsNull)) { scope -> scope.setTag("context", "PerformSwipeActionManager.performSwipeAction") 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 e2ba8a6297..fbfd39aa72 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 @@ -421,12 +421,7 @@ class ActionsViewModel @Inject constructor( } private suspend fun getMessagesToDelete(threads: List?, messages: List?) = when { - threads != null -> threads.flatMap { - messageController.getUnscheduledMessagesFromThread( - it, - includeDuplicates = true - ) - } + threads != null -> threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = true) } messages != null -> messageController.getMessagesAndDuplicates(messages) else -> emptyList() } 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 93e5d1dfbb..aff85c5de8 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 @@ -102,11 +102,12 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val threads = selectedThreads.toSet() val threadsUids = threads.map { it.uid } val threadsCount = threadsUids.count() - val currentMailbox = mainViewModel.currentMailbox.value - if (currentMailbox == null) { + val currentMailbox = mainViewModel.currentMailbox.value ?: run { SentryLog.e(TAG, getString(R.string.sentryErrorMailboxIsNull)) { scope -> scope.setTag("context", "$TAG.onViewCreated") } + snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) + return@with } // Initialization of threadsUids to populate junkMessages and potentialUsersToBlock @@ -145,15 +146,11 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.spam.setClosingOnClickListener { trackMultiSelectActionEvent(MatomoName.Spam, threadsCount, isFromBottomSheet = true) - if (currentMailbox == null) { - snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) - } else { - actionsViewModel.toggleThreadsOrMessagesSpamStatus( - threads = threads, - currentFolderId = mainViewModel.currentFolderId, - mailbox = currentMailbox, - ) - } + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = threads, + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox, + ) isMultiSelectOn = false } @@ -167,21 +164,17 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { SentryLog.e(TAG, getString(R.string.sentryErrorPhishingMessagesEmpty)) } - if (currentMailbox == null) { - snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) - } else { - descriptionDialog.show( - title = getString(R.string.reportPhishingTitle), - description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), - onPositiveButtonClicked = { - actionsViewModel.reportPhishing( - messages = messages, - currentFolder = mainViewModel.currentFolder.value, - mailbox = currentMailbox - ) - }, - ) - } + descriptionDialog.show( + title = getString(R.string.reportPhishingTitle), + description = resources.getQuantityString(R.plurals.reportPhishingDescription, messages.count()), + onPositiveButtonClicked = { + actionsViewModel.reportPhishing( + messages = messages, + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailbox + ) + }, + ) isMultiSelectOn = false } @@ -210,15 +203,12 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - if (currentMailbox == null) { - snackbarManager.postValue(getString(RCore.string.anErrorHasOccurred)) - } else { - actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( - threadsUids = threadsUids, - mailbox = currentMailbox, - shouldFavorite = shouldFavorite - ) - } + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = threadsUids, + mailbox = currentMailbox, + shouldFavorite = shouldFavorite + ) + isMultiSelectOn = false } From 662c1415be9eb8ac72cc8501570b3cf3e9fbf40c Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 08:05:39 +0100 Subject: [PATCH 12/13] fix: Fix PR comments --- .../cache/mailboxContent/MessageController.kt | 2 +- .../main/folder/PerformSwipeActionManager.kt | 3 +- .../ui/main/thread/MessageWebViewClient.kt | 46 +++++++++---------- .../main/thread/actions/ActionsViewModel.kt | 9 ++-- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt index 4b78086a67..525726d1d2 100644 --- a/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt +++ b/app/src/main/java/com/infomaniak/mail/data/cache/mailboxContent/MessageController.kt @@ -141,7 +141,7 @@ class MessageController @Inject constructor( suspend fun getMessagesAndDuplicates(messages: List): List { return messages.flatMap { message -> - if (message.threads.isEmpty()) return listOf(message) + if (message.threads.isEmpty()) return@flatMap listOf(message) getMessageAndDuplicates(message.threads.first(), message) } } 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 4a01894039..42133c5b38 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 @@ -135,7 +135,6 @@ object PerformSwipeActionManager { mailbox = currentMailbox ) mainViewModel.currentFilter.value != ThreadFilter.UNSEEN - true } SwipeAction.SPAM -> { @@ -166,6 +165,8 @@ object PerformSwipeActionManager { currentMailBox: Mailbox ): Boolean { fun onCancel() { + // Notify only if the user cancelled the popup (e.g. the thread is not deleted), + // otherwise it will notify the next item in the list and make it slightly blink if (threadListAdapter.dataSet.indexOfFirstThread(thread) == position) { threadListAdapter.notifyItemChanged(position) } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt index db84bd7a82..5fb4bb5115 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt @@ -25,16 +25,12 @@ import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import com.infomaniak.core.ui.showToast -import com.infomaniak.core.ui.view.toDp import com.infomaniak.mail.R import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.Utils.runCatchingRealm -import com.infomaniak.mail.utils.WebViewVersionUtils.getWebViewVersionData -import io.sentry.Sentry -import io.sentry.SentryLevel import kotlinx.coroutines.runBlocking import java.io.ByteArrayInputStream @@ -105,27 +101,27 @@ class MessageWebViewClient( override fun onPageFinished(webView: WebView, url: String?) { runCatchingRealm { - val widthInDp = webView.width.toDp(webView) - if (widthInDp <= 0) { - val versionData = getWebViewVersionData(context) - - Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> - scope.level = SentryLevel.WARNING - scope.setExtra("width", webView.width.toString()) - scope.setExtra("measuredWidth", webView.measuredWidth.toString()) - scope.setExtra("height", webView.height.toString()) - scope.setExtra("measuredHeight", webView.measuredHeight.toString()) - scope.setTag( - "webview version", - "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" - ) - scope.setTag("visibility", webView.visibility.toString()) - scope.setTag("messageUid", messageUid) - scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) - } - } - - webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')") + // val widthInDp = webView.width.toDp(webView) + // if (widthInDp <= 0) { + // val versionData = getWebViewVersionData(context) + // + // Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> + // scope.level = SentryLevel.WARNING + // scope.setExtra("width", webView.width.toString()) + // scope.setExtra("measuredWidth", webView.measuredWidth.toString()) + // scope.setExtra("height", webView.height.toString()) + // scope.setExtra("measuredHeight", webView.measuredHeight.toString()) + // scope.setTag( + // "webview version", + // "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" + // ) + // scope.setTag("visibility", webView.visibility.toString()) + // scope.setTag("messageUid", messageUid) + // scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) + // } + // } + + // webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')") onPageFinished?.invoke() } } 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 fbfd39aa72..9141ee61c1 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 @@ -147,9 +147,8 @@ class ActionsViewModel @Inject constructor( snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) return@launch } - restrictions.apply { - blockedSenders.removeIf { it.email == email } - } + restrictions.blockedSenders.removeIf { it.email == email } + updateBlockedSenders(mailbox, restrictions) } } @@ -172,9 +171,9 @@ class ActionsViewModel @Inject constructor( } val destinationFolder = folderController.getFolder(destinationFolderRole)!! - val unscheduleMessages = messageController.getUnscheduledMessages(messages) + val unscheduledMessages = messageController.getUnscheduledMessages(messages) - moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduleMessages, displaySnackbar) + moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduledMessages, displaySnackbar) } private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { From af9d70aad76ce7e7dd2d91451467a61c5f55287f Mon Sep 17 00:00:00 2001 From: Sol Rubado Date: Mon, 23 Feb 2026 08:19:38 +0100 Subject: [PATCH 13/13] fix: Remove comment in webview --- .../ui/main/thread/MessageWebViewClient.kt | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt index 5fb4bb5115..db84bd7a82 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/MessageWebViewClient.kt @@ -25,12 +25,16 @@ import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient import com.infomaniak.core.ui.showToast +import com.infomaniak.core.ui.view.toDp import com.infomaniak.mail.R import com.infomaniak.mail.data.api.ApiRepository import com.infomaniak.mail.data.models.Attachment import com.infomaniak.mail.utils.LocalStorageUtils import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.Utils.runCatchingRealm +import com.infomaniak.mail.utils.WebViewVersionUtils.getWebViewVersionData +import io.sentry.Sentry +import io.sentry.SentryLevel import kotlinx.coroutines.runBlocking import java.io.ByteArrayInputStream @@ -101,27 +105,27 @@ class MessageWebViewClient( override fun onPageFinished(webView: WebView, url: String?) { runCatchingRealm { - // val widthInDp = webView.width.toDp(webView) - // if (widthInDp <= 0) { - // val versionData = getWebViewVersionData(context) - // - // Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> - // scope.level = SentryLevel.WARNING - // scope.setExtra("width", webView.width.toString()) - // scope.setExtra("measuredWidth", webView.measuredWidth.toString()) - // scope.setExtra("height", webView.height.toString()) - // scope.setExtra("measuredHeight", webView.measuredHeight.toString()) - // scope.setTag( - // "webview version", - // "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" - // ) - // scope.setTag("visibility", webView.visibility.toString()) - // scope.setTag("messageUid", messageUid) - // scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) - // } - // } - - // webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')") + val widthInDp = webView.width.toDp(webView) + if (widthInDp <= 0) { + val versionData = getWebViewVersionData(context) + + Sentry.captureMessage("Zero width webview detected onPageFinished which prevents message width's normalization") { scope -> + scope.level = SentryLevel.WARNING + scope.setExtra("width", webView.width.toString()) + scope.setExtra("measuredWidth", webView.measuredWidth.toString()) + scope.setExtra("height", webView.height.toString()) + scope.setExtra("measuredHeight", webView.measuredHeight.toString()) + scope.setTag( + "webview version", + "${versionData?.webViewPackageName}: ${versionData?.versionName} - ${versionData?.majorVersion}" + ) + scope.setTag("visibility", webView.visibility.toString()) + scope.setTag("messageUid", messageUid) + scope.setTag("shouldLoadDistantResources", shouldLoadDistantResources.toString()) + } + } + + webView.loadUrl("javascript:removeAllProperties(); normalizeMessageWidth($widthInDp, '$messageUid')") onPageFinished?.invoke() } }