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..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 @@ -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) { @@ -135,6 +139,13 @@ class MessageController @Inject constructor( messagesAsync.await() + duplicatesAsync.await() } + suspend fun getMessagesAndDuplicates(messages: List): List { + return messages.flatMap { message -> + if (message.threads.isEmpty()) return@flatMap listOf(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..22385327f0 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) } @@ -269,7 +271,7 @@ class MainActivity : BaseActivity() { } private fun observeActivityDialogLoaderReset() { - mainViewModel.activityDialogLoaderResetTrigger.observe(this) { descriptionDialog.resetLoadingAndDismiss() } + actionsViewModel.activityDialogLoaderResetTrigger.observe(this) { descriptionDialog.resetLoadingAndDismiss() } } private fun observeDraftWorkerResults() { @@ -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/MainViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt index 8e45606931..776ff226d0 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, @@ -183,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) @@ -502,43 +476,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 +609,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.getUnscheduledMessages(it, includeDuplicates = true) } - else -> messageController.getMessageAndDuplicates(threads.first(), message) - } fun deleteDraft(targetMailboxUuid: String, remoteDraftUuid: String) = viewModelScope.launch(ioCoroutineContext) { val mailbox = currentMailbox.value!! @@ -865,14 +691,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( @@ -987,260 +813,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.getUnscheduledMessages(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 +840,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 @@ -1559,10 +887,10 @@ class MainViewModel @Inject constructor( fun moveToNewFolder( name: String, threadsUids: List, - messageUid: String?, + messagesUids: List?, ) = viewModelScope.launch(ioCoroutineContext) { val newFolderId = createNewFolderSync(name) ?: return@launch - moveThreadsOrMessageTo(newFolderId, threadsUids, messageUid) + moveThreadsOrMessageTo(newFolderId, threadsUids, messagesUids) isMovedToNewFolder.postValue(true) } //endregion @@ -1746,8 +1074,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/folder/PerformSwipeActionManager.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folder/PerformSwipeActionManager.kt index f7c2ff6952..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 @@ -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 @@ -38,6 +40,7 @@ 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 { @@ -77,91 +80,146 @@ object PerformSwipeActionManager { 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) - } - }, - ) { - mainViewModel.archiveThread(thread.uid) + ): Boolean { + 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") } + return 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) - mainViewModel.deleteThread(thread.uid) - }, - ) - } - SwipeAction.FAVORITE -> { - mainViewModel.toggleThreadFavoriteStatus(thread.uid) - 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 - ), + + return when (swipeAction) { + SwipeAction.TUTORIAL -> { + localSettings.setDefaultSwipeActions() + safelyNavigate(ThreadListFragmentDirections.actionThreadListFragmentToSettingsFragment()) + findNavController().navigate(R.id.swipeActionsSettingsFragment, args = null, getAnimatedNavOptions()) + true + } + SwipeAction.ARCHIVE -> { + handleArchiveSwipe(thread, position, folderRole, currentMailbox) + } + SwipeAction.DELETE -> { + handleDeleteSwipe(thread, position, folderRole, isPermanentDeleteFolder, currentMailbox) + } + SwipeAction.FAVORITE -> { + 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 -> { + actionsViewModel.toggleThreadsOrMessagesSeenStatus( + threadsUids = listOf(thread.uid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox ) - ) - true + mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + } + + SwipeAction.SPAM -> { + 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.READ_UNREAD -> { - mainViewModel.toggleThreadSeenStatus(thread.uid) - mainViewModel.currentFilter.value != ThreadFilter.UNSEEN + } + + private fun ThreadListFragment.handleArchiveSwipe( + thread: Thread, + position: Int, + folderRole: FolderRole?, + 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) + } } - SwipeAction.SPAM -> { - mainViewModel.toggleThreadSpamStatus(listOf(thread.uid)) - false + + fun onSuccess() { + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailBox + ) } - SwipeAction.SNOOZE -> { - val snoozeScheduleType = if (thread.isSnoozed()) { - SnoozeScheduleType.Modify(thread.uid) - } else { - SnoozeScheduleType.Snooze(thread.uid) + + return descriptionDialog.archiveWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = ::onCancel, + onPositiveButtonClicked = ::onSuccess + ) + } + + private fun ThreadListFragment.handleDeleteSwipe( + thread: Thread, + position: Int, + folderRole: FolderRole?, + isPermanentDeleteFolder: Boolean, + 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) } - navigateToSnoozeBottomSheet(snoozeScheduleType, thread.snoozeEndDate) - true } - SwipeAction.NONE -> error("Cannot swipe on an action which is not set") + + fun onHandleDelete() { + if (isPermanentDeleteFolder) threadListAdapter.removeItem(position) + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = currentMailBox + ) + } + + return descriptionDialog.deleteWithConfirmationPopup( + folderRole = folderRole, + count = 1, + displayLoader = false, + onCancel = ::onCancel, + callback = ::onHandleDelete, + ) } private fun LocalSettings.setDefaultSwipeActions() { 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..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 @@ -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( + threadsUids = 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( + threadsUids = 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/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/folderPicker/FolderPickerFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/folderPicker/FolderPickerFragment.kt index e162505854..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 @@ -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 @@ -125,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()) }, ) } @@ -151,10 +153,12 @@ class FolderPickerFragment : Fragment() { private fun onFolderSelected(folder: Folder?): Unit = with(navigationArgs) { when (action) { FolderPickerAction.MOVE -> folder?.id?.let { - mainViewModel.moveThreadsOrMessageTo( - it, - threadsUids.toList(), - messageUid + actionsViewModel.moveThreadsOrMessagesTo( + destinationFolderId = it, + threadsUids = threadsUids.toList(), + messagesUids = messagesUids?.toList(), + currentFolderId = mainViewModel.currentFolderId, + mailbox = 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 6e46e678a2..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 @@ -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 activityViewModels() private val twoPaneFragment inline get() = parentFragment as TwoPaneFragment private val threadAdapter inline get() = binding.messagesList.adapter as ThreadAdapter @@ -257,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) } @@ -369,10 +371,14 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { }, onUnsubscribeClicked = threadViewModel::unsubscribeMessage, moveMessageToSpam = { messageUid -> - twoPaneViewModel.currentThreadUid.value?.let { mainViewModel.moveToSpamFolder(it, messageUid) } + actionsViewModel.moveToSpamFolder( + messagesUid = listOf(messageUid), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) }, - activateSpamFilter = mainViewModel::activateSpamFilter, - unblockMail = mainViewModel::unblockMail, + activateSpamFilter = { actionsViewModel.activateSpamFilter(mainViewModel.currentMailbox.value!!) }, + unblockMail = { actionsViewModel.unblockMail(it, mainViewModel.currentMailbox.value!!) }, replyToCalendarEvent = { attendanceState, message -> replyToCalendarEvent( attendanceState, @@ -420,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) @@ -665,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) + }) } } @@ -774,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 = Date(timestamp), + threadUids = threadUids, + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) if (isSuccess) twoPaneViewModel.closeThread() } } @@ -783,7 +806,11 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { lifecycleScope.launch { binding.snoozeAlert.showAction1Progress() - val result = mainViewModel.rescheduleSnoozedThreads(Date(timestamp), threadUids) + val result = actionsViewModel.rescheduleSnoozedThreads( + date = Date(timestamp), + threadUids = threadUids, + mailbox = mainViewModel.currentMailbox.value!! + ) binding.snoozeAlert.hideAction1Progress(R.string.buttonModify) if (result is BatchSnoozeResult.Success) twoPaneViewModel.closeThread() @@ -798,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 @@ -822,13 +852,24 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { R.id.quickActionArchive -> { descriptionDialog.archiveWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Archive, isFromArchive) - mainViewModel.archiveThread(threadUid) + val thread = threadViewModel.threadLive.value ?: return@archiveWithConfirmationPopup + actionsViewModel.archiveThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) } } R.id.quickActionDelete -> { descriptionDialog.deleteWithConfirmationPopup(folderRole, count = 1) { trackThreadActionsEvent(MatomoName.Delete) - mainViewModel.deleteThread(threadUid) + val thread = threadViewModel.threadLive.value ?: return@deleteWithConfirmationPopup + actionsViewModel.deleteThreadsOrMessages( + threads = listOf(thread), + currentFolder = mainViewModel.currentFolder.value, + mailbox = mainViewModel.currentMailbox.value!! + ) + } } R.id.quickActionMenu -> { @@ -996,7 +1037,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 new file mode 100644 index 0000000000..9141ee61c1 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionsViewModel.kt @@ -0,0 +1,911 @@ +/* + * 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.annotation.StringRes +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +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.mailbox.SendersRestrictions +import com.infomaniak.mail.data.models.message.Message +import com.infomaniak.mail.data.models.snooze.BatchSnoozeResult +import com.infomaniak.mail.data.models.thread.Thread +import com.infomaniak.mail.di.IoDispatcher +import com.infomaniak.mail.ui.main.SnackbarManager +import com.infomaniak.mail.ui.main.SnackbarManager.UndoData +import com.infomaniak.mail.utils.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.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, + private val threadController: ThreadController, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : AndroidViewModel(application) { + + private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher) + + val isDownloadingChanges: MutableLiveData = MutableLiveData(false) + + val activityDialogLoaderResetTrigger = 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 toggleThreadsOrMessagesSpamStatus( + messages: List? = null, + threads: Set? = null, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true + ) = viewModelScope.launch(ioCoroutineContext) { + val messagesToMarkAsSpam = when { + threads != null -> getMessagesFromThreadToSpamOrHam(threads) + messages != null -> messageController.getUnscheduledMessages(messages) + else -> emptyList() + } + + toggleMessagesSpamStatus(messagesToMarkAsSpam, currentFolderId, mailbox, displaySnackbar) + } + + fun activateSpamFilter(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) { + ApiRepository.setSpamFilter( + mailboxHostingId = mailbox.hostingId, + mailboxName = mailbox.mailboxName, + activateSpamFilter = true, + ) + } + + fun unblockMail(email: String, mailbox: Mailbox?) = viewModelScope.launch(ioCoroutineContext) { + if (mailbox == null) return@launch + + with(ApiRepository.getSendersRestrictions(mailbox.hostingId, mailbox.mailboxName)) { + if (isSuccess()) { + val restrictions = data + if (restrictions == null) { + snackbarManager.postValue(appContext.getString(RCore.string.anErrorHasOccurred)) + return@launch + } + restrictions.blockedSenders.removeIf { it.email == email } + + updateBlockedSenders(mailbox, restrictions) + } + } + } + + private fun toggleMessagesSpamStatus( + messages: List, + currentFolderId: String?, + mailbox: Mailbox, + displaySnackbar: Boolean = true, + ) = viewModelScope.launch(ioCoroutineContext) { + + val folder = if (currentFolderId != null) folderController.getFolder(currentFolderId) else null + val folderRole = folderRoleUtils.getActionFolderRole(messages, folder) + + val destinationFolderRole = if (folderRole == FolderRole.SPAM) { + FolderRole.INBOX + } else { + FolderRole.SPAM + } + val destinationFolder = folderController.getFolder(destinationFolderRole)!! + + val unscheduledMessages = messageController.getUnscheduledMessages(messages) + + moveMessagesTo(destinationFolder, currentFolderId, mailbox, unscheduledMessages, displaySnackbar) + } + + private suspend fun getMessagesFromThreadToSpamOrHam(threads: Set): List { + return threads.flatMap { messageController.getUnscheduledMessagesFromThread(it, includeDuplicates = false) } + } + + 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 + fun moveThreadsOrMessagesTo( + destinationFolderId: String, + threadsUids: 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 = messagesUids?.let { messageController.getMessages(it) } + val messagesToMove = sharedUtils.getMessagesToMove(threads, messages, currentFolderId) + + 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 featureFlags = mailbox.featureFlags + + val apiResponses = moveMessages( + mailbox = mailbox, + messagesToMove = messages, + destinationFolder = destinationFolder, + alsoMoveReactionMessages = FeatureAvailability.isReactionsAvailable(featureFlags, localSettings), + ) + + if (apiResponses.atLeastOneSucceeded() && currentFolderId != null) { + + refreshFoldersAsync( + mailbox = mailbox, + messagesFoldersIds = messages.getFoldersIds(exception = destinationFolder.id), + destinationFolderId = destinationFolder.id, + currentFolderId = currentFolderId, + callbacks = RefreshCallbacks(onStart = ::onDownloadStart, onStop = { onDownloadStop(movedThreads) }), + ) + } + + if (apiResponses.atLeastOneFailed() && movedThreads.isNotEmpty()) { + threadController.updateIsLocallyMovedOutStatus(movedThreads, hasBeenMovedOut = false) + } + + 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, + messagesMoved: List, + apiResponses: List>, + destinationFolder: Folder, + ) { + + val destination = destinationFolder.getLocalizedName(appContext) + + val snackbarTitle = when { + apiResponses.allFailed() -> appContext.getString(apiResponses.first().translateError()) + threadsMoved.count() > 0 || messagesMoved.count() > 1 -> appContext.resources.getQuantityString( + R.plurals.snackbarThreadMoved, + threadsMoved.count(), + destination + ) + else -> appContext.getString(R.string.snackbarMessageMoved, destination) + } + + val undoResources = apiResponses.mapNotNull { it.data?.undoResource } + val undoData = if (undoResources.isEmpty()) { + null + } else { + val undoDestinationId = destinationFolder.id + val foldersIds = messagesMoved.getFoldersIds(exception = undoDestinationId) + foldersIds += destinationFolder.id + UndoData( + resources = apiResponses.mapNotNull { it.data?.undoResource }, + foldersIds = foldersIds, + destinationFolderId = undoDestinationId, + ) + } + + snackbarManager.postValue(snackbarTitle, undoData) + } + //endregion + + //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(messages, currentFolder, 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( + threadsUids = uidsToMove, + hasBeenMovedOut = false + ) + + val undoDestinationId = messagesToDelete.first().folderId + val undoFoldersIds = messagesToDelete.getFoldersIds(exception = undoDestinationId) + showDeleteSnackbar( + apiResponses = apiResponses, + messages = messagesToDelete, + undoResources = undoResources, + undoFoldersIds = undoFoldersIds, + undoDestinationId = undoDestinationId, + numberOfImpactedThreads = messagesToDelete.count(), + ) + } + + private fun showDeleteSnackbar( + apiResponses: List>, + messages: List, + undoResources: List, + undoFoldersIds: ImpactedFolders, + undoDestinationId: String?, + numberOfImpactedThreads: Int, + ) { + val snackbarTitle = if (apiResponses.atLeastOneSucceeded()) { + if (messages.count() > 1) { + 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) ?: return@launch + + moveMessagesTo(destinationFolder, currentFolder?.id, mailbox, messages) + } + + //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 -> + 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 = 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 + } + + 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 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 + 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) + } + + private fun onDownloadStop(threadsUids: List = emptyList()) = viewModelScope.launch(ioCoroutineContext) { + threadController.updateIsLocallyMovedOutStatus(threadsUids, hasBeenMovedOut = false) + isDownloadingChanges.postValue(false) + } + + private fun refreshFoldersAsync( + mailbox: Mailbox, + messagesFoldersIds: ImpactedFolders, + currentFolderId: String? = null, + destinationFolderId: String? = null, + callbacks: RefreshCallbacks? = null, + ) = viewModelScope.launch(ioCoroutineContext) { + sharedUtils.refreshFolders(mailbox, messagesFoldersIds, destinationFolderId, currentFolderId, callbacks) + } + + private suspend fun moveOutThreadsLocally(messages: List, destinationFolder: Folder): List { + val uidsToMove = mutableListOf().apply { + messages.flatMapTo(mutableSetOf(), Message::threads).forEach { thread -> + val nbMessagesInCurrentFolder = thread.messages.count { it.folderId != destinationFolder.id } + if (nbMessagesInCurrentFolder == 0) add(thread.uid) + } + } + + if (uidsToMove.isNotEmpty()) threadController.updateIsLocallyMovedOutStatus(uidsToMove, hasBeenMovedOut = true) + return uidsToMove + } + + 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 af273d5d41..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 @@ -61,6 +61,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { private val navigationArgs: MessageActionsBottomSheetDialogArgs by navArgs() + private val actionsViewModel: ActionsViewModel by activityViewModels() private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val currentClassName: String by lazy { MessageActionsBottomSheetDialog::class.java.name } @@ -111,7 +112,7 @@ class MessageActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } private fun observeReportPhishingResult() { - mainViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { + actionsViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { descriptionDialog.resetLoadingAndDismiss() findNavController().popBackStack() } @@ -159,7 +160,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 @@ -168,13 +173,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() } @@ -185,9 +198,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(), ) @@ -207,12 +220,19 @@ 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) - mainViewModel.toggleMessageSpamStatus(threadUid, message) + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + messages = listOf(message), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) } override fun onPhishing() { @@ -220,7 +240,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 dc8d29ca00..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 @@ -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 @@ -101,6 +102,13 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { val threads = selectedThreads.toSet() val threadsUids = threads.map { it.uid } val threadsCount = threadsUids.count() + 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 junkMessagesViewModel.threadsUids = threadsUids @@ -110,7 +118,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) @@ -132,13 +140,17 @@ 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) - toggleThreadSpamStatus(threadsUids) + actionsViewModel.toggleThreadsOrMessagesSpamStatus( + threads = threads, + currentFolderId = mainViewModel.currentFolderId, + mailbox = currentMailbox, + ) isMultiSelectOn = false } @@ -155,8 +167,16 @@ 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 = currentMailbox + ) + }, ) + + isMultiSelectOn = false } binding.blockSender.setClosingOnClickListener { @@ -178,12 +198,17 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { junkMessagesViewModel.messageOfUserToBlock.value = message } } - mainViewModel.isMultiSelectOn = false + isMultiSelectOn = false } binding.favorite.setClosingOnClickListener(shouldCloseMultiSelection = true) { trackMultiSelectActionEvent(MatomoName.Favorite, threadsCount, isFromBottomSheet = true) - toggleThreadsFavoriteStatus(threadsUids, shouldFavorite) + actionsViewModel.toggleThreadsOrMessagesFavoriteStatus( + threadsUids = threadsUids, + mailbox = currentMailbox, + shouldFavorite = shouldFavorite + ) + isMultiSelectOn = false } @@ -198,17 +223,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, getString(R.string.sentryErrorMailboxIsNull)) { 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 = threadsUids, + shouldRead = shouldRead, + currentFolderId = currentFolderId, + mailbox = currentMailbox + ) } R.id.actionArchive -> { descriptionDialog.archiveWithConfirmationPopup( @@ -216,7 +257,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 -> { @@ -225,7 +270,11 @@ class MultiSelectBottomSheetDialog : ActionsBottomSheetDialog() { count = threadsCount, ) { trackMultiSelectActionEvent(MatomoName.Delete, threadsCount, isFromBottomSheet = true) - mainViewModel.deleteThreads(threadsUids) + actionsViewModel.deleteThreadsOrMessages( + threads = threads.toList(), + currentFolder = currentFolder, + mailbox = currentMailbox + ) } } } @@ -254,7 +303,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 f0de8a2c66..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 @@ -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 } @@ -133,7 +134,7 @@ class ThreadActionsBottomSheetDialog : MailActionsBottomSheetDialog() { } private fun observeReportPhishingResult() { - mainViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { + actionsViewModel.reportPhishingTrigger.observe(viewLifecycleOwner) { descriptionDialog.resetLoadingAndDismiss() findNavController().popBackStack() } @@ -178,7 +179,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 @@ -187,13 +192,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() } @@ -229,23 +242,25 @@ 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() { - 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.toggleThreadsOrMessagesSpamStatus( + threads = setOf(thread), + currentFolderId = mainViewModel.currentFolderId, + mailbox = mainViewModel.currentMailbox.value!! + ) } override fun onPhishing() { @@ -261,7 +276,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..476d895dd0 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 { + messages != null -> messages.filter { message -> message.folderId == currentFolderId && !message.isScheduledMessage } + threads != null -> threads.flatMap { messageController.getMovableMessages(it) } + else -> emptyList() //this should never happen, we have to send a list of threads or messages. + } + suspend fun refreshFolders( mailbox: Mailbox, messagesFoldersIds: ImpactedFolders, 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[]" /> 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