diff --git a/app-common/build.gradle.kts b/app-common/build.gradle.kts index 18b574bf5ae..4a729005147 100644 --- a/app-common/build.gradle.kts +++ b/app-common/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(projects.feature.mail.message.export.api) implementation(projects.feature.mail.message.export.implEml) + implementation(projects.feature.mail.message.list.api) implementation(projects.feature.mail.message.reader.api) implementation(projects.feature.mail.message.reader.impl) diff --git a/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt b/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt index 0b961863e9e..1277f8a76a5 100644 --- a/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt +++ b/app-common/src/main/kotlin/net/thunderbird/app/common/AppCommonModule.kt @@ -1,5 +1,6 @@ package net.thunderbird.app.common +import com.fsck.k9.K9 import com.fsck.k9.legacyCommonAppModules import com.fsck.k9.legacyCoreModules import com.fsck.k9.legacyUiModules @@ -8,8 +9,10 @@ import net.thunderbird.app.common.appConfig.AndroidPlatformConfigProvider import net.thunderbird.app.common.core.appCommonCoreModule import net.thunderbird.app.common.feature.appCommonFeatureModule import net.thunderbird.core.common.appConfig.PlatformConfigProvider +import net.thunderbird.feature.mail.message.list.extension.toSortType import org.koin.core.module.Module import org.koin.dsl.module +import net.thunderbird.feature.mail.message.list.domain.DomainContract as MessageListDomainContract val appCommonModule: Module = module { includes(legacyCommonAppModules) @@ -23,4 +26,10 @@ val appCommonModule: Module = module { ) single { AndroidPlatformConfigProvider() } + + single { + MessageListDomainContract.UseCase.GetDefaultSortType { + K9.sortType.toSortType(isAscending = K9.isSortAscending(K9.sortType)) + } + } } diff --git a/app-k9mail/src/test/kotlin/app/k9mail/DependencyInjectionTest.kt b/app-k9mail/src/test/kotlin/app/k9mail/DependencyInjectionTest.kt index 9da93210e2b..af893d38746 100644 --- a/app-k9mail/src/test/kotlin/app/k9mail/DependencyInjectionTest.kt +++ b/app-k9mail/src/test/kotlin/app/k9mail/DependencyInjectionTest.kt @@ -23,8 +23,10 @@ import com.fsck.k9.view.K9WebViewClient import com.fsck.k9.view.MessageWebView import net.openid.appauth.AppAuthConfiguration import net.thunderbird.core.common.mail.html.HtmlSettings +import net.thunderbird.core.logging.Logger import net.thunderbird.core.preference.storage.Storage import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import net.thunderbird.feature.mail.message.reader.api.css.CssClassNameProvider import org.junit.Test @@ -68,6 +70,7 @@ class DependencyInjectionTest { definition(WorkerParameters::class), definition(LifecycleOwner::class), definition(SetupArchiveFolderDialogContract.State::class), + definition(Logger::class, List::class), ), ) } diff --git a/app-thunderbird/src/test/kotlin/net/thunderbird/android/DependencyInjectionTest.kt b/app-thunderbird/src/test/kotlin/net/thunderbird/android/DependencyInjectionTest.kt index 1213d55afb2..8abd1491f21 100644 --- a/app-thunderbird/src/test/kotlin/net/thunderbird/android/DependencyInjectionTest.kt +++ b/app-thunderbird/src/test/kotlin/net/thunderbird/android/DependencyInjectionTest.kt @@ -23,8 +23,10 @@ import com.fsck.k9.view.K9WebViewClient import com.fsck.k9.view.MessageWebView import net.openid.appauth.AppAuthConfiguration import net.thunderbird.core.common.mail.html.HtmlSettings +import net.thunderbird.core.logging.Logger import net.thunderbird.core.preference.storage.Storage import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import net.thunderbird.feature.mail.message.reader.api.css.CssClassNameProvider import org.junit.Test @@ -68,6 +70,7 @@ class DependencyInjectionTest { definition(WorkerParameters::class), definition(LifecycleOwner::class), definition(SetupArchiveFolderDialogContract.State::class), + definition(Logger::class, List::class), ), ) } diff --git a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt index f75207b992c..5de44a5d266 100644 --- a/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt +++ b/core/common/src/commonMain/kotlin/net/thunderbird/core/common/inject/KoinMultibindingCollection.kt @@ -3,7 +3,7 @@ package net.thunderbird.core.common.inject import org.koin.core.definition.Definition import org.koin.core.module.KoinDslMarker import org.koin.core.module.Module -import org.koin.core.parameter.parametersOf +import org.koin.core.parameter.ParametersDefinition import org.koin.core.qualifier.Qualifier import org.koin.core.qualifier.named import org.koin.core.scope.Scope @@ -24,8 +24,8 @@ import org.koin.core.scope.Scope */ @KoinDslMarker inline fun Module.singleListOf(vararg items: Definition, qualifier: Qualifier? = null) { - single(qualifier ?: defaultListQualifier(), createdAtStart = true) { - items.map { definition -> definition(this, parametersOf()) } + single(qualifier ?: defaultListQualifier(), createdAtStart = true) { parameters -> + items.map { definition -> definition(this, parameters) } } } @@ -43,8 +43,8 @@ inline fun Module.singleListOf(vararg items: Definition, qualifie */ @KoinDslMarker inline fun Module.factoryListOf(vararg items: Definition, qualifier: Qualifier? = null) { - factory(qualifier ?: defaultListQualifier()) { - items.map { definition -> definition(this, parametersOf()) } + factory(qualifier ?: defaultListQualifier()) { parametersHolder -> + items.map { definition -> definition(this, parametersHolder) } } } @@ -58,8 +58,10 @@ inline fun Module.factoryListOf(vararg items: Definition, qualifi * @param qualifier An optional [Qualifier] to distinguish between different lists of the same type. * @return The resolved [MutableList] of instances of type [T]. */ -inline fun Scope.getList(qualifier: Qualifier? = null) = - get>(qualifier ?: defaultListQualifier()) +inline fun Scope.getList( + qualifier: Qualifier? = null, + noinline parameters: ParametersDefinition? = null, +) = get>(qualifier ?: defaultListQualifier(), parameters = parameters) /** * Creates a qualifier for a set of a specific type. diff --git a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/state/StateMachineTest.kt b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/state/StateMachineTest.kt index 966abe4ca5d..69dc050e0e4 100644 --- a/core/common/src/commonTest/kotlin/net/thunderbird/core/common/state/StateMachineTest.kt +++ b/core/common/src/commonTest/kotlin/net/thunderbird/core/common/state/StateMachineTest.kt @@ -253,6 +253,7 @@ class StateMachineTest { stateMachine(scope = this) { initialState(State.Init) { onEnter { event, newState -> + println("New state: $newState, event: $event") actualPreviousState = this actualNewState = newState actualEvent = event diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt index 89efa47fc4d..ac19179eabd 100644 --- a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/BaseViewModel.kt @@ -42,7 +42,7 @@ abstract class BaseViewModel( * * @param update A function that takes the current [STATE] and produces a new [STATE]. */ - protected fun updateState(update: (STATE) -> STATE) { + protected open fun updateState(update: (STATE) -> STATE) { _state.update(update) } diff --git a/feature/mail/message/list/api/build.gradle.kts b/feature/mail/message/list/api/build.gradle.kts index 587a84f2f81..65b1bf44af6 100644 --- a/feature/mail/message/list/api/build.gradle.kts +++ b/feature/mail/message/list/api/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { api(projects.core.outcome) + implementation(projects.core.android.account) implementation(projects.core.common) implementation(projects.core.featureflag) implementation(projects.core.preference.api) diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/domain/DomainContract.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/domain/DomainContract.kt index e5ee0836db3..01a4b255028 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/domain/DomainContract.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/domain/DomainContract.kt @@ -7,6 +7,8 @@ import net.thunderbird.core.outcome.Outcome import net.thunderbird.feature.account.AccountId import net.thunderbird.feature.mail.folder.api.FolderServerId import net.thunderbird.feature.mail.folder.api.RemoteFolder +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.state.SortType interface DomainContract { interface UseCase { @@ -31,6 +33,18 @@ interface DomainContract { fun interface BuildSwipeActions { operator fun invoke(): StateFlow> } + + fun interface GetMessageListPreferences { + operator fun invoke(): Flow + } + + fun interface GetSortTypes { + suspend operator fun invoke(accountIds: Set): Map + } + + fun interface GetDefaultSortType { + suspend operator fun invoke(): SortType + } } } diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/extension/DomainSortTypeToUiSortType.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/extension/DomainSortTypeToUiSortType.kt new file mode 100644 index 00000000000..d5b1892b9ee --- /dev/null +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/extension/DomainSortTypeToUiSortType.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.mail.message.list.extension + +import net.thunderbird.feature.mail.message.list.ui.state.SortType +import net.thunderbird.core.android.account.SortType as DomainSortType + +/** + * Maps a [DomainSortType] from the domain layer to a [SortType] in the UI layer. + * + * This extension function takes the domain-level sort criteria and a boolean indicating + * the sort direction to produce the corresponding specific UI sort type. + * + * @param isAscending `true` for ascending order, `false` for descending order. + * @return The corresponding [SortType] for the UI layer. + */ +@Suppress("ComplexMethod") +fun DomainSortType.toSortType(isAscending: Boolean): SortType = when (this) { + DomainSortType.SORT_DATE if isAscending -> SortType.DateAsc + DomainSortType.SORT_DATE -> SortType.DateDesc + DomainSortType.SORT_ARRIVAL if isAscending -> SortType.ArrivalAsc + DomainSortType.SORT_ARRIVAL -> SortType.ArrivalDesc + DomainSortType.SORT_SUBJECT if isAscending -> SortType.SubjectAsc + DomainSortType.SORT_SUBJECT -> SortType.SubjectDesc + DomainSortType.SORT_SENDER if isAscending -> SortType.SenderAsc + DomainSortType.SORT_SENDER -> SortType.SenderDesc + DomainSortType.SORT_UNREAD if isAscending -> SortType.UnreadAsc + DomainSortType.SORT_UNREAD -> SortType.UnreadDesc + DomainSortType.SORT_FLAGGED if isAscending -> SortType.FlaggedAsc + DomainSortType.SORT_FLAGGED -> SortType.FlaggedDesc + DomainSortType.SORT_ATTACHMENT if isAscending -> SortType.AttachmentAsc + DomainSortType.SORT_ATTACHMENT -> SortType.AttachmentDesc +} + +/** + * Maps a [SortType] from the UI layer to a [DomainSortType] in the domain layer. + * + * This extension function takes a specific UI-level sort type and decomposes it into its + * domain-level sort criteria and a boolean indicating the sort direction. + * + * @return A [Pair] where the first element is the [DomainSortType] and the second + * is a [Boolean] indicating the sort order (`true` for ascending, `false` for descending). + */ +@Suppress("ComplexMethod") +fun SortType.toDomainSortType(): Pair = when (this) { + SortType.DateAsc -> DomainSortType.SORT_DATE to true + SortType.DateDesc -> DomainSortType.SORT_DATE to false + SortType.ArrivalAsc -> DomainSortType.SORT_ARRIVAL to true + SortType.ArrivalDesc -> DomainSortType.SORT_ARRIVAL to false + SortType.SenderAsc -> DomainSortType.SORT_SENDER to true + SortType.SenderDesc -> DomainSortType.SORT_SENDER to false + SortType.UnreadAsc -> DomainSortType.SORT_UNREAD to true + SortType.UnreadDesc -> DomainSortType.SORT_UNREAD to false + SortType.FlaggedAsc -> DomainSortType.SORT_FLAGGED to true + SortType.FlaggedDesc -> DomainSortType.SORT_FLAGGED to false + SortType.AttachmentAsc -> DomainSortType.SORT_ATTACHMENT to true + SortType.AttachmentDesc -> DomainSortType.SORT_ATTACHMENT to false + SortType.SubjectAsc -> DomainSortType.SORT_SUBJECT to true + SortType.SubjectDesc -> DomainSortType.SORT_SUBJECT to false +} diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/preferences/MessageListPreferences.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/preferences/MessageListPreferences.kt index bf28d0a5407..d91298199b6 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/preferences/MessageListPreferences.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/preferences/MessageListPreferences.kt @@ -15,11 +15,13 @@ import net.thunderbird.core.preference.display.visualSettings.message.list.UiDen * @property showCorrespondentNames Whether to display the names of correspondents. * @property showMessageAvatar Whether to display the contact's avatar. * @property showFavouriteButton Whether to display a button to mark a message as a favourite (starred). + * @property senderAboveSubject Whether to display sender information above the subject. * @property excerptLines The number of lines to show for a message excerpt. * @property dateTimeFormat The format for displaying the date and time of messages. * @property useVolumeKeyNavigation Whether to enable navigating between messages using the volume keys. * @property serverSearchLimit The maximum number of results to fetch when performing a server search. * @property actionRequiringUserConfirmation A set of actions that require a confirmation dialog before execution. + * @property colorizeBackgroundWhenRead Whether to colorize the background of read messages. */ data class MessageListPreferences( val density: UiDensity, @@ -27,11 +29,13 @@ data class MessageListPreferences( val showCorrespondentNames: Boolean, val showMessageAvatar: Boolean, val showFavouriteButton: Boolean, + val senderAboveSubject: Boolean, val excerptLines: Int, val dateTimeFormat: MessageListDateTimeFormat, val useVolumeKeyNavigation: Boolean, val serverSearchLimit: Int, val actionRequiringUserConfirmation: ImmutableSet = persistentSetOf(), + val colorizeBackgroundWhenRead: Boolean = false, ) /** diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt new file mode 100644 index 00000000000..3af715659ff --- /dev/null +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/MessageListContract.kt @@ -0,0 +1,12 @@ +package net.thunderbird.feature.mail.message.list.ui + +import app.k9mail.core.ui.compose.common.mvi.BaseViewModel +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +interface MessageListContract { + abstract class ViewModel : BaseViewModel( + initialState = MessageListState.WarmingUp(), + ) +} diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/effect/MessageListEffect.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/effect/MessageListEffect.kt index b4ce249c14c..d5724b3006e 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/effect/MessageListEffect.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/effect/MessageListEffect.kt @@ -8,6 +8,13 @@ import net.thunderbird.feature.account.AccountId * or showing transient messages. */ sealed interface MessageListEffect { + /** + * Effect to trigger a refresh of the message list. This can be used to manually + * reload the list of messages from the data source, for instance, after a pull-to-refresh + * gesture or a programmatic trigger. + */ + data object RefreshMessageList : MessageListEffect + /** * Effect to navigate back from the current screen. */ diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt index bcaf48541b5..f9c5fdfee17 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageListEvent.kt @@ -89,7 +89,9 @@ sealed interface MessageListEvent { /** * An event that is triggered when the user changes the sort order of the message list. * + * @param accountId The [AccountId] of the account for which the sort order is being changed. When `null`, + * the sort type is for the Unified Inbox. * @param sortType The new [SortType] to apply to the message list. */ - data class ChangeSortType(val sortType: SortType) : UserEvent + data class ChangeSortType(val accountId: AccountId?, val sortType: SortType) : UserEvent } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt index 1f133f0636b..78226c7cc6f 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModule.kt @@ -1,12 +1,23 @@ package net.thunderbird.feature.mail.message.list.internal +import net.thunderbird.core.common.inject.factoryListOf +import net.thunderbird.core.common.inject.getList import net.thunderbird.feature.mail.message.list.domain.DomainContract import net.thunderbird.feature.mail.message.list.internal.domain.usecase.BuildSwipeActions import net.thunderbird.feature.mail.message.list.internal.domain.usecase.CreateArchiveFolder import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetAccountFolders +import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetMessageListPreferences +import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetSortTypes import net.thunderbird.feature.mail.message.list.internal.domain.usecase.SetArchiveFolder +import net.thunderbird.feature.mail.message.list.internal.ui.MessageListViewModel import net.thunderbird.feature.mail.message.list.internal.ui.dialog.SetupArchiveFolderDialogFragment import net.thunderbird.feature.mail.message.list.internal.ui.dialog.SetupArchiveFolderDialogViewModel +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSortTypeStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSwipeActionsStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadedConfigStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.StateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.MessageListContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory import org.koin.core.module.dsl.viewModel @@ -47,4 +58,45 @@ val featureMessageListModule = module { ) as SetupArchiveFolderDialogContract.ViewModel } factory { SetupArchiveFolderDialogFragment.Factory } + factory { + GetMessageListPreferences( + displayPreferenceManager = get(), + interactionPreferenceManager = get(), + ) + } + factory { + GetSortTypes( + accountManager = get(), + getDefaultSortType = get(), + ) + } + factoryListOf( + { parameters -> + LoadSwipeActionsStateSideEffectHandler.Factory( + logger = get(), + buildSwipeActions = get(), + ) + }, + { parameters -> + LoadSortTypeStateSideEffectHandler.Factory( + accounts = parameters.get(), + logger = get(), + getSortTypes = get(), + ) + }, + { LoadedConfigStateSideEffectHandler.Factory(logger = get()) }, + ) + factory { parameters -> + MessageListStateMachine.Factory( + logger = get(), + stateSideEffectHandlersFactories = getList(parameters = { parameters }), + ) + } + viewModel { parameters -> + MessageListViewModel( + logger = get(), + messageListStateMachineFactory = get { parameters }, + getMessageListPreferences = get(), + ) + } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetMessageListPreferences.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetMessageListPreferences.kt new file mode 100644 index 00000000000..d2b0d46818e --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetMessageListPreferences.kt @@ -0,0 +1,62 @@ +package net.thunderbird.feature.mail.message.list.internal.domain.usecase + +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import net.thunderbird.core.preference.display.DisplaySettingsPreferenceManager +import net.thunderbird.core.preference.interaction.InteractionSettings +import net.thunderbird.core.preference.interaction.InteractionSettingsPreferenceManager +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.preferences.ActionRequiringUserConfirmation +import net.thunderbird.feature.mail.message.list.preferences.MessageListDateTimeFormat +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences + +class GetMessageListPreferences( + private val displayPreferenceManager: DisplaySettingsPreferenceManager, + private val interactionPreferenceManager: InteractionSettingsPreferenceManager, +) : DomainContract.UseCase.GetMessageListPreferences { + override fun invoke(): Flow = displayPreferenceManager + .getConfigFlow() + .combine(interactionPreferenceManager.getConfigFlow()) { displaySettings, interactionSettings -> + val inboxSettings = displaySettings.inboxSettings + val messageListSettings = displaySettings.visualSettings.messageListSettings + + MessageListPreferences( + density = messageListSettings.uiDensity, + groupConversations = inboxSettings.isThreadedViewEnabled, + showCorrespondentNames = messageListSettings.isShowCorrespondentNames, + showMessageAvatar = messageListSettings.isShowContactPicture, + showFavouriteButton = inboxSettings.isShowMessageListStars, + senderAboveSubject = inboxSettings.isMessageListSenderAboveSubject, + excerptLines = messageListSettings.previewLines, + // TODO(#10202): update to fetch dateTimeFormat from preferences + dateTimeFormat = MessageListDateTimeFormat.Auto, + useVolumeKeyNavigation = interactionSettings.useVolumeKeysForNavigation, + serverSearchLimit = -1, + actionRequiringUserConfirmation = interactionSettings.actionRequiringUserConfirmation.toImmutableSet(), + colorizeBackgroundWhenRead = messageListSettings.isUseBackgroundAsUnreadIndicator, + ) + } + + private val InteractionSettings.actionRequiringUserConfirmation: Set + get() = buildSet { + if (isConfirmDelete) { + add(ActionRequiringUserConfirmation.Delete) + } + if (isConfirmDeleteStarred) { + add(ActionRequiringUserConfirmation.DeleteStarred) + } + if (isConfirmDeleteFromNotification) { + add(ActionRequiringUserConfirmation.DeleteFromNotification) + } + if (isConfirmSpam) { + add(ActionRequiringUserConfirmation.Spam) + } + if (isConfirmDiscardMessage) { + add(ActionRequiringUserConfirmation.DiscardMessage) + } + if (isConfirmMarkAllRead) { + add(ActionRequiringUserConfirmation.MarkAllRead) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetSortTypes.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetSortTypes.kt new file mode 100644 index 00000000000..88df6d6e40f --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/domain/usecase/GetSortTypes.kt @@ -0,0 +1,32 @@ +package net.thunderbird.feature.mail.message.list.internal.domain.usecase + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.thunderbird.core.android.account.LegacyAccountManager +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.extension.toSortType +import net.thunderbird.feature.mail.message.list.ui.state.SortType + +class GetSortTypes( + private val accountManager: LegacyAccountManager, + private val getDefaultSortType: DomainContract.UseCase.GetDefaultSortType, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : DomainContract.UseCase.GetSortTypes { + override suspend operator fun invoke(accountIds: Set): Map { + val accounts = withContext(ioDispatcher) { + accountManager.getAccounts() + } + val sortTypes = buildMap { + put(null, getDefaultSortType()) + putAll( + accounts + .associate { + it.id to it.sortType.toSortType(isAscending = it.sortAscending[it.sortType] ?: false) + }, + ) + } + return sortTypes + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt new file mode 100644 index 00000000000..6b6bbd1224e --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListViewModel.kt @@ -0,0 +1,62 @@ +package net.thunderbird.feature.mail.message.list.internal.ui + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine +import net.thunderbird.feature.mail.message.list.ui.MessageListContract +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +private const val TAG = "MessageListViewModel" + +class MessageListViewModel( + private val logger: Logger, + messageListStateMachineFactory: MessageListStateMachine.Factory, + getMessageListPreferences: DomainContract.UseCase.GetMessageListPreferences, +) : MessageListContract.ViewModel() { + private val stateMachine = messageListStateMachineFactory.create( + scope = viewModelScope, + dispatch = ::event, + ) + override val state: StateFlow = stateMachine.currentState + + init { + logger.debug(TAG) { "init() called" } + stateMachine + .currentState + .onEach { state -> + logger.debug(TAG) { "stateMachine.currentState() called with: state = $state" } + when (state) { + // TODO(#10251): Required as the current implementation of sortType and sortAscending + // returns null before we load the sort type. That should be removed when + // the message list item's load is switched to the new state. + is MessageListState.LoadingMessages -> emitEffect(MessageListEffect.RefreshMessageList) + else -> Unit + } + } + .launchIn(viewModelScope) + getMessageListPreferences() + .onEach { preferences -> + logger.verbose(TAG) { "getMessageListPreferences() called with: preferences = $preferences" } + event(MessageListEvent.UpdatePreferences(preferences)) + } + .launchIn(viewModelScope) + } + + override fun updateState(update: (MessageListState) -> MessageListState) { + error("updateState() is not supported by this ViewModel. The state must be updated using the state machine.") + } + + override fun event(event: MessageListEvent) { + logger.verbose(TAG) { "event() called with: event = $event" } + viewModelScope.launch { + stateMachine.onEvent(event) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt new file mode 100644 index 00000000000..08daa3d5763 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachine.kt @@ -0,0 +1,86 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.common.state.StateMachine +import net.thunderbird.core.common.state.builder.stateMachine +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.StateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +private const val TAG = "MessageListStateMachine" + +/** + * Manages the state transitions for the message list UI. + * + * This class orchestrates the overall state of the message list screen by processing incoming [MessageListEvent]s + * and transitioning between different [MessageListState]s. It encapsulates the core logic for how the UI should + * react to user actions and data loading events. + * + * The state machine is defined using a DSL that configures transitions for various states like loading, + * displaying messages, selection mode, and search mode. + * + * Upon a state change, it delegates to a list of [StateSideEffectHandler]s, which are responsible for + * executing side effects such as fetching data from a repository, navigating, or showing toasts. This separation + * keeps the state management logic clean and focused. + * + * @param logger For logging state transitions and events. + * @param stateSideEffectHandlersFactories A list of factories to create the side effect handlers. + * @param dispatch A function to send new events back into the state machine, allowing for event-driven side effects. + * @param stateMachine The underlying state machine implementation, configured with all possible states and transitions. + */ +class MessageListStateMachine( + private val scope: CoroutineScope, + private val logger: Logger, + private val stateSideEffectHandlersFactories: List, + private val dispatch: (MessageListEvent) -> Unit, + private val stateMachine: StateMachine = stateMachine(scope) { + warmingUpInitialState(initialState = MessageListState.WarmingUp(), dispatch) + globalState() + loadingMessagesState() + loadedMessagesState() + selectingMessagesState() + searchingMessagesState() + }, +) : StateMachine by stateMachine { + private val sideEffectHandlers = stateSideEffectHandlersFactories.map { it.create(scope, dispatch) } + + /** + * Processes an incoming [MessageListEvent] to transition the state machine and trigger side effects. + * + * This function takes an event, feeds it to the underlying state machine to compute the next state. + * If a state transition occurs (i.e., the new state is different from the current one), it logs the change + * and then delegates to the appropriate [StateSideEffectHandler]s. + * + * Only side effect handlers that [StateSideEffectHandler.accept] the given event and the new state + * will have their [StateSideEffectHandler.handle] method called. + * + * @param event The [MessageListEvent] to be processed. + */ + suspend fun onEvent(event: MessageListEvent) { + val currentState = stateMachine.currentStateSnapshot + val newState = stateMachine.process(event) + if (newState != currentState) { + logger.verbose(TAG) { "event(${event::class.simpleName}): state update." } + sideEffectHandlers + .filter { it.accept(event, newState) } + .forEach { it.handle(event, currentState, newState) } + } + } + + class Factory( + private val logger: Logger, + private val stateSideEffectHandlersFactories: List, + ) { + fun create(scope: CoroutineScope, dispatch: (MessageListEvent) -> Unit): MessageListStateMachine { + val stateMachine = MessageListStateMachine( + scope = scope, + logger = logger, + stateSideEffectHandlersFactories = stateSideEffectHandlersFactories, + dispatch = dispatch, + ) + + return stateMachine + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt new file mode 100644 index 00000000000..63cd8fbf138 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupGlobalState.kt @@ -0,0 +1,52 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.collections.immutable.toPersistentMap +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Sets up global state transitions that can occur from any state. + * + * This includes transitions for events that are not specific to a single state, + * such as updating user preferences. By defining these transitions on the parent + * [MessageListState], we avoid duplicating the logic in every single sub-state. + */ +@Suppress("CyclomaticComplexMethod") +internal fun StateMachineBuilder.globalState() { + state { + transition { state, event -> + when (state) { + is MessageListState.LoadedMessages -> state.copy(preferences = event.preferences) + is MessageListState.LoadingMessages -> state.copy(preferences = event.preferences) + is MessageListState.SearchingMessages -> state.copy(preferences = event.preferences) + is MessageListState.SelectingMessages -> state.copy(preferences = event.preferences) + is MessageListState.WarmingUp -> state.copy(preferences = event.preferences) + } + } + + transition { state, (accountId, sortType) -> + val newSelectedSortTypes = state.selectedSortTypes.toMutableMap().apply { + this[accountId] = sortType + }.toPersistentMap() + when (state) { + is MessageListState.LoadedMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.LoadingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.SearchingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.SelectingMessages -> state.copy(selectedSortTypes = newSelectedSortTypes) + is MessageListState.WarmingUp -> state.copy(selectedSortTypes = newSelectedSortTypes) + } + } + + transition { state, (swipeActions) -> + when (state) { + is MessageListState.LoadedMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.LoadingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.SearchingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.SelectingMessages -> state.copy(swipeActions = swipeActions.toImmutableMap()) + is MessageListState.WarmingUp -> state.copy(swipeActions = swipeActions.toImmutableMap()) + } + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt new file mode 100644 index 00000000000..f673a134410 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadedMessagesState.kt @@ -0,0 +1,65 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListSearchEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for the [MessageListState.LoadedMessages] state. + * + * This state represents the default view where the message list has been successfully + * loaded and is displayed to the user. + * From here, the user can transition into selection mode or search mode. + * + * Transitions: + * - On [MessageItemEvent.ToggleSelectMessages]: Moves to [MessageListState.SelectingMessages], + * toggling the selected state of the specified messages. + * - On [MessageListEvent.EnterSelectionMode]: Moves to [MessageListState.SelectingMessages] + * without changing any message's selected state. + * - On [MessageListSearchEvent.EnterSearchMode]: Moves to [MessageListState.SearchingMessages] + * with an empty search query. + */ +internal fun StateMachineBuilder.loadedMessagesState() { + state { + transition { state, event -> + MessageListState.SelectingMessages( + folder = null, + messages = state.messages.map { message -> + if (message in event.messages) message.copy(selected = !message.selected) else message + }.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + transition { state, _ -> + MessageListState.SelectingMessages( + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + transition { state, _ -> + MessageListState.SearchingMessages( + searchQuery = "", + isServerSearch = false, + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt new file mode 100644 index 00000000000..d775f5f366a --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupLoadingMessagesState.kt @@ -0,0 +1,36 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the behavior of the state machine when it is in the [MessageListState.LoadingMessages] state. + * + * This state handles the following transitions: + * - On [MessageListEvent.UpdateLoadingProgress]: Updates the loading progress indicator. + * - On [MessageListEvent.MessagesLoaded]: Transitions to the [MessageListState.LoadedMessages] state, but only + * if the loading progress has reached 100% (`progress == 1f`). This ensures a smooth transition + * after the loading animation completes. + */ +internal fun StateMachineBuilder.loadingMessagesState() { + state { + transition { state, event -> + state.copy(progress = event.progress) + } + transition( + guard = { state, _ -> state.progress == 1f }, + ) { state, event -> + MessageListState.LoadedMessages( + folder = null, + messages = event.messages.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = null, + isActive = true, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt new file mode 100644 index 00000000000..4bb126f7ebc --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSearchingMessagesState.kt @@ -0,0 +1,45 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListSearchEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for the [MessageListState.SearchingMessages] state. + * + * This state handles the logic for when the user is actively searching for messages. It manages + * transitions for updating the search query, switching to a remote (server-side) search, and + * exiting the search mode to return to the normal message list view. + * + * - On [MessageListSearchEvent.UpdateSearchQuery]: Updates the current search query and resets to local search. + * - On [MessageListSearchEvent.SearchRemotely]: Sets the search to be performed on the server. + * - On [MessageListSearchEvent.ExitSearchMode]: Transitions back to the [MessageListState.LoadedMessages] state, + * effectively ending the search. + */ +internal fun StateMachineBuilder.searchingMessagesState() { + state { + transition { state, event -> + state.copy(searchQuery = event.query) + } + + transition { state, _ -> + state.copy( + searchQuery = state.searchQuery, + isServerSearch = true, + ) + } + + transition { state, _ -> + MessageListState.LoadedMessages( + folder = null, + messages = state.messages, + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt new file mode 100644 index 00000000000..e808da0810d --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupSelectingMessagesState.kt @@ -0,0 +1,40 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toPersistentList +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the state transitions for when the user is actively selecting messages. + * + * This state is entered when the user long-presses a message, initiating selection mode. + * + * It handles the following events: + * - [MessageItemEvent.ToggleSelectMessages]: Toggles the selection status of one or more messages. + * - [MessageListEvent.ExitSelectionMode]: Exits selection mode, deselecting all messages and returning + * to the [MessageListState.LoadedMessages] state. + */ +internal fun StateMachineBuilder.selectingMessagesState() { + state { + transition { state, event -> + state.copy( + messages = state.messages.map { message -> + if (message in event.messages) message.copy(selected = !message.selected) else message + }.toPersistentList(), + ) + } + transition { state, _ -> + MessageListState.LoadedMessages( + folder = null, + messages = state.messages.map { message -> message.copy(selected = false) }.toPersistentList(), + preferences = state.preferences, + swipeActions = state.swipeActions, + selectedSortTypes = state.selectedSortTypes, + activeMessage = state.activeMessage, + isActive = state.isActive, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt new file mode 100644 index 00000000000..c54c34ea6da --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/SetupWarmingUpInitialState.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import kotlinx.collections.immutable.toImmutableMap +import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * Defines the behavior for the [MessageListState.WarmingUp] state. + * + * This state is responsible for loading initial configurations, such as user preferences, + * swipe actions, and sort types, before transitioning to the message loading state. + * + * - On entering this state, it dispatches [MessageListEvent.LoadConfigurations] to trigger the loading process. + * - It handles updates for preferences, swipe actions, and sort types as they become available. + * - Once all required configurations are loaded ([MessageListState.WarmingUp.isReady] is `true`), + * it transitions to the [MessageListState.LoadingMessages] state upon receiving the + * [MessageListEvent.AllConfigsReady] event. + * + * @param initialState The initial [MessageListState.WarmingUp] instance. + * @param dispatch A function to send events to the state machine. + */ +internal fun StateMachineBuilder.warmingUpInitialState( + initialState: MessageListState.WarmingUp, + dispatch: (MessageListEvent) -> Unit = {}, +) { + initialState(state = initialState) { + onEnter { _, _ -> + dispatch(MessageListEvent.LoadConfigurations) + } + transition { state, _ -> state.copy(isActive = true) } + transition { state, event -> + state.copy(preferences = event.preferences) + } + transition { state, event -> + state.copy(selectedSortTypes = event.sortTypes.toImmutableMap()) + } + transition( + guard = { state, _ -> state.isReady }, + ) { state, _ -> + MessageListState.LoadingMessages( + progress = 0f, + swipeActions = state.swipeActions, + preferences = requireNotNull(state.preferences), + selectedSortTypes = state.selectedSortTypes, + folder = null, + ) + } + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortTypeStateSideEffectHandler.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortTypeStateSideEffectHandler.kt new file mode 100644 index 00000000000..f00a0d30134 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortTypeStateSideEffectHandler.kt @@ -0,0 +1,42 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +private const val TAG = "LoadSortTypeSideEffectHandler" + +class LoadSortTypeStateSideEffectHandler( + private val accounts: Set, + dispatch: suspend (MessageListEvent) -> Unit, + private val logger: Logger, + private val getSortTypes: DomainContract.UseCase.GetSortTypes, +) : StateSideEffectHandler(logger, dispatch) { + override fun accept(event: MessageListEvent, newState: MessageListState): Boolean = + event == MessageListEvent.LoadConfigurations + + override suspend fun handle(oldState: MessageListState, newState: MessageListState) { + logger.verbose(TAG) { "$TAG.handle() called with: oldState = $oldState, newState = $newState" } + val sortTypes = getSortTypes(accountIds = accounts) + dispatch(MessageListEvent.SortTypesLoaded(sortTypes)) + } + + class Factory( + private val accounts: Set, + private val logger: Logger, + private val getSortTypes: DomainContract.UseCase.GetSortTypes, + ) : StateSideEffectHandler.Factory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + ): StateSideEffectHandler = LoadSortTypeStateSideEffectHandler( + accounts = accounts, + dispatch = dispatch, + logger = logger, + getSortTypes = getSortTypes, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSwipeActionsStateSideEffectHandler.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSwipeActionsStateSideEffectHandler.kt new file mode 100644 index 00000000000..89ea1d4f854 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSwipeActionsStateSideEffectHandler.kt @@ -0,0 +1,50 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +private const val TAG = "LoadSwipeActionsSideEffectHandler" + +class LoadSwipeActionsStateSideEffectHandler( + private val scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + private val logger: Logger, + private val buildSwipeActions: DomainContract.UseCase.BuildSwipeActions, +) : StateSideEffectHandler(logger, dispatch) { + var runningFlow: Job? = null + override fun accept(event: MessageListEvent, newState: MessageListState): Boolean = + event == MessageListEvent.LoadConfigurations + + override suspend fun handle(oldState: MessageListState, newState: MessageListState) { + logger.verbose(TAG) { "$TAG.handle() called with: oldState = $oldState, newState = $newState" } + runningFlow?.cancel() + runningFlow = buildSwipeActions() + .onEach { swipeActions -> + dispatch(MessageListEvent.SwipeActionsLoaded(swipeActions)) + } + .onCompletion { runningFlow = null } + .launchIn(scope) + } + + class Factory( + private val logger: Logger, + private val buildSwipeActions: DomainContract.UseCase.BuildSwipeActions, + ) : StateSideEffectHandler.Factory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + ): StateSideEffectHandler = LoadSwipeActionsStateSideEffectHandler( + dispatch = dispatch, + scope = scope, + logger = logger, + buildSwipeActions = buildSwipeActions, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadedConfigStateSideEffectHandler.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadedConfigStateSideEffectHandler.kt new file mode 100644 index 00000000000..d007faf8fe6 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadedConfigStateSideEffectHandler.kt @@ -0,0 +1,28 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +private const val TAG = "LoadedConfigSideEffectHandler" + +class LoadedConfigStateSideEffectHandler( + private val logger: Logger, + dispatch: suspend (MessageListEvent) -> Unit, +) : StateSideEffectHandler(logger, dispatch) { + override fun accept(event: MessageListEvent, newState: MessageListState): Boolean = + newState is MessageListState.WarmingUp && newState.isReady + + override suspend fun handle(oldState: MessageListState, newState: MessageListState) { + logger.verbose(TAG) { "$TAG.handle() called with: oldState = $oldState, newState = $newState" } + dispatch(MessageListEvent.AllConfigsReady) + } + + class Factory(private val logger: Logger) : StateSideEffectHandler.Factory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + ): StateSideEffectHandler = LoadedConfigStateSideEffectHandler(logger, dispatch) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/StateSideEffectHandler.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/StateSideEffectHandler.kt new file mode 100644 index 00000000000..9a5489760be --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/StateSideEffectHandler.kt @@ -0,0 +1,64 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * A base class for handling side effects that arise from state transitions in the message list UI. + * + * Each implementation of this class is responsible for a specific side effect, such as loading more messages, + * marking messages as read, or showing notifications. The handler decides whether to act based on the + * incoming event and the resulting state change. + * + * @property logger A [Logger] for logging the handler's operations. + * @property dispatch A function to send new [MessageListEvent]s back to the UI's event loop. + */ +abstract class StateSideEffectHandler( + private val logger: Logger, + protected val dispatch: suspend (MessageListEvent) -> Unit, +) { + /** + * Determines whether this side effect handler should be triggered. + * + * This function is called for every state change to check if the conditions for executing + * this specific side effect are met. + * + * @param event The [MessageListEvent] that triggered the state change. + * @param newState The new [MessageListState] after the event was processed. + * @return `true` if this handler should execute its `handle` method, `false` otherwise. + */ + abstract fun accept(event: MessageListEvent, newState: MessageListState): Boolean + + /** + * Handles the side effect based on the state transition. + * + * This function is invoked when the `accept` method returns `true`, indicating that this handler + * should process the state change. It's responsible for executing the actual side effect, + * such as dispatching a new event, logging, or interacting with other system components. + * + * @param oldState The state before the event was processed. + * @param newState The new state after the event has been processed. + */ + abstract suspend fun handle(oldState: MessageListState, newState: MessageListState) + + /** + * Handles a state change by checking if this handler should react to the [event] and the [newState]. + * If it should, it calls the [handle] method to perform the side effect. + * + * @param event The event that triggered the state change. + * @param oldState The state before the event was processed. + * @param newState The state after the event was processed. + */ + suspend fun handle(event: MessageListEvent, oldState: MessageListState, newState: MessageListState) { + logger.verbose { "handle() called with: event = $event, oldState = $oldState, newState = $newState" } + if (accept(event, newState)) { + handle(oldState, newState) + } + } + + interface Factory { + fun create(scope: CoroutineScope, dispatch: suspend (MessageListEvent) -> Unit): StateSideEffectHandler + } +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModuleKtTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModuleKtTest.kt index c5e80aa435f..6e4c2212353 100644 --- a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModuleKtTest.kt +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/FeatureMessageListModuleKtTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import net.thunderbird.core.common.resources.StringsResourceManager import net.thunderbird.core.logging.Logger import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import org.koin.core.annotation.KoinExperimentalAPI import org.koin.test.KoinTest @@ -23,6 +24,7 @@ class FeatureMessageListModuleKtTest : KoinTest { ), injections = listOf( definition(SetupArchiveFolderDialogContract.State::class), + definition(Logger::class, List::class), ), ) } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MainActivity.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MainActivity.kt index 6c634d7d203..bc0f4da811c 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MainActivity.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MainActivity.kt @@ -42,9 +42,8 @@ import com.fsck.k9.ui.BuildConfig import com.fsck.k9.ui.R import com.fsck.k9.ui.base.BaseActivity import com.fsck.k9.ui.managefolders.ManageFoldersActivity +import com.fsck.k9.ui.messagelist.AbstractMessageListFragment import com.fsck.k9.ui.messagelist.DefaultFolderProvider -import com.fsck.k9.ui.messagelist.MessageListFragment -import com.fsck.k9.ui.messagelist.MessageListFragment.MessageListFragmentListener import com.fsck.k9.ui.messageview.MessageViewContainerFragment import com.fsck.k9.ui.messageview.MessageViewContainerFragment.MessageViewContainerListener import com.fsck.k9.ui.messageview.MessageViewFragment.MessageViewFragmentListener @@ -53,7 +52,6 @@ import com.fsck.k9.ui.settings.SettingsActivity import com.fsck.k9.view.ViewSwitcher import com.fsck.k9.view.ViewSwitcher.OnSwitchCompleteListener import com.google.android.material.textview.MaterialTextView -import kotlin.getValue import net.thunderbird.core.android.account.LegacyAccount import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.android.account.LegacyAccountDtoManager @@ -86,7 +84,7 @@ private const val TAG = "MainActivity" * "View Message" notification. * * `MainActivity` manages the overall layout, including the navigation drawer and the main content area, - * which currently displays either a [MessageListFragment] or a [MessageViewContainerFragment]. It orchestrates + * which currently displays either a [AbstractMessageListFragment] or a [MessageViewContainerFragment]. It orchestrates * the interactions between these fragments and handles the back stack. The responsibilities for managing the * action bar, search functionality, and single-pane/split-view layout logic are currently handled here but * are intended to be refactored into more dedicated components over time. @@ -94,7 +92,7 @@ private const val TAG = "MainActivity" @Suppress("TooManyFunctions", "LargeClass") open class MainActivity : BaseActivity(), - MessageListFragmentListener, + AbstractMessageListFragment.MessageListFragmentListener, MessageViewFragmentListener, MessageViewContainerListener, FragmentManager.OnBackStackChangedListener, @@ -121,7 +119,8 @@ open class MainActivity : private var openFolderTransaction: FragmentTransaction? = null private var progressBar: ProgressBar? = null private var messageViewPlaceHolder: PlaceholderFragment? = null - private var messageListFragment: MessageListFragment? = null + private val messageListFragmentFactory: AbstractMessageListFragment.Factory by inject() + private var messageListFragment: AbstractMessageListFragment? = null private var messageViewContainerFragment: MessageViewContainerFragment? = null private var account: LegacyAccountDto? = null private var search: LocalMessageSearch? = null @@ -266,12 +265,14 @@ open class MainActivity : private fun findFragments() { val fragmentManager = supportFragmentManager - messageListFragment = fragmentManager.findFragmentById(R.id.message_list_container) as? MessageListFragment + messageListFragment = fragmentManager.findFragmentById( + R.id.message_list_container, + ) as? AbstractMessageListFragment messageViewContainerFragment = fragmentManager.findFragmentByTag(FRAGMENT_TAG_MESSAGE_VIEW_CONTAINER) as? MessageViewContainerFragment messageListFragment?.let { messageListFragment -> - messageViewContainerFragment?.setViewModel(messageListFragment.viewModel) + messageViewContainerFragment?.setViewModel(messageListFragment.legacyViewModel) initializeFromLocalSearch(messageListFragment.localSearch) } } @@ -283,7 +284,7 @@ open class MainActivity : val hasMessageListFragment = messageListFragment != null if (!hasMessageListFragment) { val fragmentTransaction = fragmentManager.beginTransaction() - val messageListFragment = MessageListFragment.newInstance( + val messageListFragment = messageListFragmentFactory.newInstance( search!!, false, generalSettingsManager.getConfig() @@ -741,7 +742,7 @@ open class MainActivity : } val openFolderTransaction = fragmentManager.beginTransaction() - val messageListFragment = MessageListFragment.newInstance( + val messageListFragment = messageListFragmentFactory.newInstance( search, false, generalSettingsManager.getConfig().display.inboxSettings.isThreadedViewEnabled, @@ -1108,7 +1109,7 @@ open class MainActivity : messageViewContainerFragment = fragment messageListFragment?.let { messageListFragment -> - fragment.setViewModel(messageListFragment.viewModel) + fragment.setViewModel(messageListFragment.legacyViewModel) } if (displayMode == DisplayMode.SPLIT_VIEW) { @@ -1159,7 +1160,7 @@ open class MainActivity : } } - private fun addMessageListFragment(fragment: MessageListFragment) { + private fun addMessageListFragment(fragment: AbstractMessageListFragment) { messageListFragment?.isActive = false supportFragmentManager.commit { @@ -1229,7 +1230,11 @@ open class MainActivity : initializeFromLocalSearch(tmpSearch) - val fragment = MessageListFragment.newInstance(tmpSearch, true, false) + val fragment = messageListFragmentFactory.newInstance( + search = tmpSearch, + isThreadDisplay = true, + threadedList = false, + ) addMessageListFragment(fragment) } @@ -1459,7 +1464,7 @@ open class MainActivity : } } - private fun MessageListFragment.setFullyActive() { + private fun AbstractMessageListFragment.setFullyActive() { isActive = true onFullyActive() } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt index 113258af5ba..b7ca534c2ed 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/KoinModule.kt @@ -5,9 +5,14 @@ import app.k9mail.legacy.message.controller.MessagingControllerMailChecker import com.fsck.k9.controller.MessagingController import com.fsck.k9.ui.helper.DisplayHtmlUiFactory import com.fsck.k9.ui.helper.SizeFormatter +import com.fsck.k9.ui.messagelist.AbstractMessageListFragment +import com.fsck.k9.ui.messagelist.LegacyMessageListFragment +import com.fsck.k9.ui.messagelist.MessageListFragment import com.fsck.k9.ui.messageview.LinkTextHandler import com.fsck.k9.ui.share.ShareIntentBuilder import net.thunderbird.core.common.inject.getList +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.feature.mail.message.list.MessageListFeatureFlags import org.koin.core.qualifier.named import org.koin.dsl.module @@ -25,4 +30,12 @@ val uiModule = module { factory { (context: Context) -> SizeFormatter(context.resources) } factory { ShareIntentBuilder(resourceProvider = get(), textPartFinder = get(), quoteDateFormatter = get()) } factory { LinkTextHandler(context = get(), clipboardManager = get()) } + factory { + val featureFlagProvider = get() + if (featureFlagProvider.provide(MessageListFeatureFlags.EnableMessageListNewState).isEnabled()) { + MessageListFragment.Factory + } else { + LegacyMessageListFragment.Factory + } + } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/AbstractMessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/AbstractMessageListFragment.kt new file mode 100644 index 00000000000..d7f6460259c --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/AbstractMessageListFragment.kt @@ -0,0 +1,2625 @@ +package com.fsck.k9.ui.messagelist + +import android.app.SearchManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.StringRes +import androidx.appcompat.view.ActionMode +import androidx.compose.animation.animateContentSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.isGone +import androidx.core.view.isVisible +import androidx.core.view.setPadding +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import app.k9mail.core.android.common.contact.ContactRepository +import app.k9mail.feature.launcher.FeatureLauncherActivity +import app.k9mail.feature.launcher.FeatureLauncherTarget +import app.k9mail.legacy.message.controller.MessageReference +import app.k9mail.legacy.message.controller.MessagingControllerRegistry +import app.k9mail.legacy.message.controller.SimpleMessagingListener +import app.k9mail.legacy.ui.folder.FolderNameFormatter +import app.k9mail.ui.utils.itemtouchhelper.ItemTouchHelper +import app.k9mail.ui.utils.linearlayoutmanager.LinearLayoutManager +import com.fsck.k9.K9 +import com.fsck.k9.activity.FolderInfoHolder +import com.fsck.k9.activity.Search +import com.fsck.k9.activity.misc.ContactPicture +import com.fsck.k9.controller.MessagingControllerWrapper +import com.fsck.k9.fragment.ConfirmationDialogFragment +import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.fsck.k9.helper.Utility +import com.fsck.k9.helper.mapToSet +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mailstore.LocalStoreProvider +import com.fsck.k9.search.getLegacyAccounts +import com.fsck.k9.ui.BuildConfig +import com.fsck.k9.ui.R +import com.fsck.k9.ui.changelog.RecentChangesActivity +import com.fsck.k9.ui.changelog.RecentChangesViewModel +import com.fsck.k9.ui.choosefolder.ChooseFolderActivity +import com.fsck.k9.ui.choosefolder.ChooseFolderResultContract +import com.fsck.k9.ui.helper.RelativeDateTimeFormatter +import com.fsck.k9.ui.messagelist.AbstractMessageListFragment.MessageListFragmentListener.Companion.MAX_PROGRESS +import com.fsck.k9.ui.messagelist.debug.AuthDebugActions +import com.fsck.k9.ui.messagelist.item.MessageViewHolder +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textview.MaterialTextView +import java.util.concurrent.Future +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import net.jcip.annotations.GuardedBy +import net.thunderbird.core.android.account.Expunge +import net.thunderbird.core.android.account.LegacyAccount +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.android.account.LegacyAccountManager +import net.thunderbird.core.android.account.SortType +import net.thunderbird.core.android.network.ConnectivityManager +import net.thunderbird.core.common.action.SwipeAction +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.common.mail.Flag +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.outcome.Outcome +import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.core.preference.display.visualSettings.message.list.DisplayMessageListSettings +import net.thunderbird.core.preference.interaction.InteractionSettings +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator +import net.thunderbird.feature.mail.folder.api.OutboxFolderManager +import net.thunderbird.feature.mail.message.list.domain.DomainContract +import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory +import net.thunderbird.feature.notification.api.content.InAppNotification +import net.thunderbird.feature.notification.api.content.SentFolderNotFoundNotification +import net.thunderbird.feature.notification.api.ui.InAppNotificationHost +import net.thunderbird.feature.notification.api.ui.action.NotificationAction +import net.thunderbird.feature.notification.api.ui.dialog.ErrorNotificationsDialogFragmentActionListener +import net.thunderbird.feature.notification.api.ui.dialog.ErrorNotificationsDialogFragmentFactory +import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag +import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual +import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration +import net.thunderbird.feature.search.legacy.LocalMessageSearch +import net.thunderbird.feature.search.legacy.SearchAccount +import net.thunderbird.feature.search.legacy.serialization.LocalMessageSearchSerializer +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf + +private const val MAXIMUM_MESSAGE_SORT_OVERRIDES = 3 +private const val MINIMUM_CLICK_INTERVAL = 200L +private const val RECENT_CHANGES_SNACKBAR_DURATION = 10 * 1000 + +@Suppress( + "LargeClass", + "TooManyFunctions", + "CyclomaticComplexMethod", + "TooGenericExceptionCaught", + "TooGenericExceptionThrown", + "SwallowedException", + "ReturnCount", + "ForbiddenComment", +) +abstract class AbstractMessageListFragment : + Fragment(), + ConfirmationDialogFragmentListener, + MessageListItemActionListener, + ErrorNotificationsDialogFragmentActionListener { + + abstract val logTag: String + + val legacyViewModel: MessageListViewModel by viewModel() + private val viewModel: MessageListViewModel get() = legacyViewModel + private val recentChangesViewModel: RecentChangesViewModel by viewModel() + + private val generalSettingsManager: GeneralSettingsManager by inject() + private val sortTypeToastProvider: SortTypeToastProvider by inject() + private val folderNameFormatter: FolderNameFormatter by inject { parametersOf(requireContext()) } + private val messagingController: MessagingControllerWrapper by inject() + private val messagingControllerRegistry: MessagingControllerRegistry by inject() + private val accountManager: LegacyAccountManager by inject() + private val connectivityManager: ConnectivityManager by inject() + private val localStoreProvider: LocalStoreProvider by inject() + + @OptIn(ExperimentalTime::class) + private val clock: Clock by inject() + private val setupArchiveFolderDialogFragmentFactory: SetupArchiveFolderDialogFragmentFactory by inject() + private val buildSwipeActions: DomainContract.UseCase.BuildSwipeActions by inject() + private val featureFlagProvider: FeatureFlagProvider by inject() + private val featureThemeProvider: FeatureThemeProvider by inject() + private val logger: Logger by inject() + private val outboxFolderManager: OutboxFolderManager by inject() + private val authDebugActions: AuthDebugActions by inject() + private val errorNotificationsDialogFragmentFactory: ErrorNotificationsDialogFragmentFactory by inject() + + private val handler = MessageListHandler(this) + private val activityListener = MessageListActivityListener() + private val actionModeCallback = ActionModeCallback() + + private val contactRepository: ContactRepository by inject() + private val avatarMonogramCreator: AvatarMonogramCreator by inject() + + private val chooseFolderForMoveLauncher: ActivityResultLauncher = + registerForActivityResult(ChooseFolderResultContract(ChooseFolderActivity.Action.MOVE)) { result -> + handleChooseFolderResult(result) { folderId, messages -> + move(messages, folderId) + } + } + private val chooseFolderForCopyLauncher: ActivityResultLauncher = + registerForActivityResult(ChooseFolderResultContract(ChooseFolderActivity.Action.COPY)) { result -> + handleChooseFolderResult(result) { folderId, messages -> + copy(messages, folderId) + } + } + + private lateinit var fragmentListener: MessageListFragmentListener + + private lateinit var recentChangesSnackbar: Snackbar + private var coordinatorLayout: CoordinatorLayout? = null + private var recyclerView: RecyclerView? = null + private var itemTouchHelper: ItemTouchHelper? = null + private var swipeRefreshLayout: SwipeRefreshLayout? = null + private var floatingActionButton: FloatingActionButton? = null + + private lateinit var adapter: MessageListAdapter + + protected lateinit var accountUuids: Array + private var accounts: List = emptyList() + + protected var account: LegacyAccount? = null + + private var currentFolder: FolderInfoHolder? = null + private var remoteSearchFuture: Future<*>? = null + private var extraSearchResults: List? = null + private var threadTitle: String? = null + private var allAccounts = false + protected open var sortType = SortType.SORT_DATE + protected open var sortAscending = true + private var sortDateAscending = false + private var actionMode: ActionMode? = null + private var hasConnectivity: Boolean? = null + private var isShowFloatingActionButton: Boolean = true + + /** + * Relevant messages for the current context when we have to remember the chosen messages + * between user interactions (e.g. selecting a folder for move operation). + */ + private var activeMessages: List? = null + private var showingThreadedList = false + private var isThreadDisplay = false + private var activeMessage: MessageReference? = null + private var rememberedSelected: Set? = null + private var lastMessageClick = 0L + + lateinit var localSearch: LocalMessageSearch + private set + var isSingleAccountMode = false + private set + private var isSingleFolderMode = false + private var isRemoteSearch = false + private var initialMessageListLoad = true + + private val isUnifiedFolders: Boolean + get() = localSearch.id == SearchAccount.UNIFIED_FOLDERS + + private val isNewMessagesView: Boolean + get() = localSearch.id == SearchAccount.NEW_MESSAGES + + /** + * `true` after [.onCreate] was executed. Used in [.updateTitle] to + * make sure we don't access member variables before initialization is complete. + */ + private var isInitialized = false + + private var error: Error? = null + + private var messageListSwipeCallback: MessageListSwipeCallback? = null + private val interactionSettings: InteractionSettings + get() = generalSettingsManager.getConfig().interaction + private val messageListSettings: DisplayMessageListSettings + get() = generalSettingsManager.getConfig().display.visualSettings.messageListSettings + + /** + * Set this to `true` when the fragment should be considered active. When active, the fragment adds its actions to + * the toolbar. When inactive, the fragment won't add its actions to the toolbar, even it is still visible, e.g. as + * part of an animation. + */ + var isActive: Boolean = false + set(value) { + field = value + resetActionMode() + invalidateMenu() + maybeHideFloatingActionButton() + } + + val isShowAccountIndicator: Boolean + get() = isUnifiedFolders || !isSingleAccountMode + + override fun onAttach(context: Context) { + super.onAttach(context) + + fragmentListener = try { + context as MessageListFragmentListener + } catch (e: ClassCastException) { + error("${context.javaClass} must implement MessageListFragmentListener") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + + restoreInstanceState(savedInstanceState) + val error = decodeArguments() + if (error != null) { + this.error = error + return + } + + viewModel.getMessageListLiveData().observe(this) { messageListInfo: MessageListInfo -> + setMessageList(messageListInfo) + } + + adapter = createMessageListAdapter() + + generalSettingsManager.getSettingsFlow() + /** + * Skips the first emitted item from the settings flow, + * since the initial value of `showingThreadedList` is taken + * from the fragment's arguments rather than the flow. + */ + .drop(1) + .map { it.display.inboxSettings.isThreadedViewEnabled } + .distinctUntilChanged() + .onEach { + showingThreadedList = it + loadMessageList(forceUpdate = true) + } + .launchIn(lifecycleScope) + + isInitialized = true + } + + private fun restoreInstanceState(savedInstanceState: Bundle?) { + if (savedInstanceState == null) return + + activeMessages = savedInstanceState.getStringArray(STATE_ACTIVE_MESSAGES) + ?.map { MessageReference.parse(it)!! } + restoreSelectedMessages(savedInstanceState) + isRemoteSearch = savedInstanceState.getBoolean(STATE_REMOTE_SEARCH_PERFORMED) + val messageReferenceString = savedInstanceState.getString(STATE_ACTIVE_MESSAGE) + activeMessage = MessageReference.parse(messageReferenceString) + } + + private fun restoreSelectedMessages(savedInstanceState: Bundle) { + rememberedSelected = savedInstanceState.getLongArray(STATE_SELECTED_MESSAGES)?.toSet() + } + + protected fun decodeArguments(): Error? { + val arguments = requireArguments() + showingThreadedList = arguments.getBoolean(ARG_THREADED_LIST, false) + isThreadDisplay = arguments.getBoolean(ARG_IS_THREAD_DISPLAY, false) + + localSearch = arguments.getByteArray(ARG_SEARCH)?.let { + LocalMessageSearchSerializer.deserialize(it) + }!! + + allAccounts = localSearch.searchAllAccounts() + val searchAccounts = localSearch.getLegacyAccounts(accountManager).also { + accounts = it + } + if (searchAccounts.size == 1) { + isSingleAccountMode = true + val singleAccount = searchAccounts[0] + account = singleAccount + accountUuids = arrayOf(singleAccount.uuid) + } else { + isSingleAccountMode = false + account = null + accountUuids = searchAccounts.map { it.uuid }.toTypedArray() + } + + isSingleFolderMode = false + if (isSingleAccountMode && localSearch.folderIds.size == 1) { + try { + val account = checkNotNull(account) + val folderId = localSearch.folderIds[0] + currentFolder = getFolderInfoHolder(account, folderId) + isSingleFolderMode = true + } catch (e: MessagingException) { + return Error.FolderNotFound + } + } + + return null + } + + private fun createMessageListAdapter(): MessageListAdapter { + @OptIn(ExperimentalTime::class) + return MessageListAdapter( + theme = requireActivity().theme, + res = resources, + layoutInflater = layoutInflater, + contactsPictureLoader = ContactPicture.getContactPictureLoader(), + listItemListener = this, + appearance = messageListAppearance, + relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock), + themeProvider = featureThemeProvider, + featureFlagProvider = featureFlagProvider, + contactRepository = contactRepository, + avatarMonogramCreator = avatarMonogramCreator, + ).apply { + activeMessage = this@AbstractMessageListFragment.activeMessage + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return if (error == null) { + inflater.inflate(R.layout.message_list_fragment, container, false).also { view -> + setFragmentResultListener( + SetupArchiveFolderDialogFragmentFactory.RESULT_CODE_DISMISS_REQUEST_KEY, + ) { key, bundle -> + logger.debug(logTag) { + "SetupArchiveFolderDialogFragment fragment listener triggered with " + + "key: $key and bundle: $bundle" + } + loadMessageList(forceUpdate = true) + } + } + } else { + inflater.inflate(R.layout.message_list_error, container, false) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (error == null) { + initializeMessageListLayout(view) + } else { + initializeErrorLayout(view) + } + } + + private fun initializeErrorLayout(view: View) { + val errorMessageView = view.findViewById(R.id.message_list_error_message) + errorMessageView.text = getString(error!!.errorText) + } + + private fun initializeMessageListLayout(view: View) { + initializeSwipeRefreshLayout(view) + initializeFloatingActionButton(view) + initializeRecyclerView(view) + initializeRecentChangesSnackbar() + + // This needs to be done before loading the message list below + initializeSortSettings() + + loadMessageList() + } + + private fun initializeSwipeRefreshLayout(view: View) { + val swipeRefreshLayout = view.findViewById(R.id.swiperefresh) + + if (isRemoteSearchAllowed) { + swipeRefreshLayout.setOnRefreshListener { onRemoteSearchRequested() } + } else if (isCheckMailSupported) { + swipeRefreshLayout.setOnRefreshListener { checkMail() } + } + + // Disable pull-to-refresh until the message list has been loaded + swipeRefreshLayout.isEnabled = false + + this.swipeRefreshLayout = swipeRefreshLayout + } + + private fun initializeFloatingActionButton(view: View) { + isShowFloatingActionButton = generalSettingsManager.getConfig() + .display + .inboxSettings + .isShowComposeButtonOnMessageList + if (isShowFloatingActionButton) { + enableFloatingActionButton(view) + } else { + disableFloatingActionButton(view) + } + + initializeFloatingActionButtonInsets(view) + } + + private fun initializeFloatingActionButtonInsets(view: View) { + val floatingActionButton = view.findViewById(R.id.floating_action_button) + + ViewCompat.setOnApplyWindowInsetsListener(floatingActionButton) { v, windowInsets -> + val insets = windowInsets.getInsets(systemBars()) + + v.updateLayoutParams { + val fabMargin = view.resources.getDimensionPixelSize(R.dimen.floatingActionButtonMargin) + + bottomMargin = fabMargin + rightMargin = fabMargin + insets.right + leftMargin = fabMargin + insets.left + } + + windowInsets + } + } + + private fun enableFloatingActionButton(view: View) { + val floatingActionButton = view.findViewById(R.id.floating_action_button) + + ViewCompat.setOnApplyWindowInsetsListener(floatingActionButton) { view, windowInsets -> + val insets = windowInsets.getInsets(systemBars()) + val margin = resources.getDimensionPixelSize(R.dimen.floatingActionButtonMargin) + + view.updateLayoutParams { + leftMargin = margin + insets.left + bottomMargin = margin + insets.bottom + rightMargin = margin + insets.right + } + + WindowInsetsCompat.CONSUMED + } + + floatingActionButton.setOnClickListener { + onCompose() + } + + this.floatingActionButton = floatingActionButton + } + + private fun disableFloatingActionButton(view: View) { + val floatingActionButton = view.findViewById(R.id.floating_action_button) + floatingActionButton.isGone = true + } + + private fun initializeRecyclerView(view: View) { + val recyclerView = view.findViewById(R.id.message_list) + + if (!isShowFloatingActionButton) { + recyclerView.setPadding(0) + } + + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.itemAnimator = MessageListItemAnimator() + + val itemTouchHelper = ItemTouchHelper( + MessageListSwipeCallback( + context = requireContext(), + scope = lifecycleScope, + resourceProvider = SwipeResourceProvider(requireContext()), + swipeActionSupportProvider = swipeActionSupportProvider, + buildSwipeActions = buildSwipeActions, + adapter = adapter, + listener = swipeListener, + accounts = accounts, + ).also { messageListSwipeCallback = it }, + ) + itemTouchHelper.attachToRecyclerView(recyclerView) + + recyclerView.adapter = adapter + + if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled) { + view.findViewById(R.id.banner_global_compose_view).apply { + setContent { + featureThemeProvider.WithTheme { + InAppNotificationHost( + onActionClick = { }, + enabled = persistentSetOf( + DisplayInAppNotificationFlag.BannerGlobalNotifications, + DisplayInAppNotificationFlag.SnackbarNotifications, + ), + onSnackbarNotificationEvent = ::onSnackbarInAppNotificationEvent, + eventFilter = ::filterInAppNotificationEvents, + modifier = Modifier + .animateContentSize() + .onSizeChanged { size -> + recyclerView.updatePadding(top = size.height) + }, + ) + } + } + } + } + + this.recyclerView = recyclerView + this.itemTouchHelper = itemTouchHelper + } + + private fun requireCoordinatorLayout(): CoordinatorLayout { + val coordinatorLayout = coordinatorLayout + ?: requireView().findViewById(R.id.message_list_coordinator) + .also { coordinatorLayout = it } + + return coordinatorLayout ?: error("Coordinator layout not initialized") + } + + private suspend fun onSnackbarInAppNotificationEvent(visual: SnackbarVisual) { + val (message, action, duration) = visual + Snackbar.make( + requireCoordinatorLayout(), + message, + when (duration) { + SnackbarDuration.Short -> Snackbar.LENGTH_SHORT + SnackbarDuration.Long -> Snackbar.LENGTH_LONG + SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE + }, + ).apply { + if (action != null) { + setAction( + action.resolveTitle(), + ) { + // TODO. + } + } + }.show() + } + + private val shouldShowRecentChangesHintObserver = Observer { showRecentChangesHint -> + val recentChangesSnackbarVisible = recentChangesSnackbar.isShown + if (showRecentChangesHint && !recentChangesSnackbarVisible) { + recentChangesSnackbar.show() + } else if (!showRecentChangesHint && recentChangesSnackbarVisible) { + recentChangesSnackbar.dismiss() + } + } + + private fun initializeRecentChangesSnackbar() { + val coordinatorLayout = requireCoordinatorLayout() + + recentChangesSnackbar = Snackbar + .make(coordinatorLayout, R.string.changelog_snackbar_text, RECENT_CHANGES_SNACKBAR_DURATION) + .setAction(R.string.changelog_snackbar_button_text) { launchRecentChangesActivity() } + .addCallback( + object : BaseCallback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + if (event == DISMISS_EVENT_SWIPE || event == DISMISS_EVENT_TIMEOUT) { + recentChangesViewModel.onRecentChangesHintDismissed() + } + } + }, + ) + + recentChangesViewModel.shouldShowRecentChangesHint + .observe(viewLifecycleOwner, shouldShowRecentChangesHintObserver) + } + + private fun launchRecentChangesActivity() { + recentChangesViewModel.shouldShowRecentChangesHint.removeObserver(shouldShowRecentChangesHintObserver) + + val intent = Intent(requireActivity(), RecentChangesActivity::class.java) + startActivity(intent) + } + + protected open fun initializeSortSettings() { + if (isSingleAccountMode) { + val account = checkNotNull(this.account) + sortType = account.sortType + sortAscending = account.sortAscending[sortType] ?: sortType.isDefaultAscending + sortDateAscending = account.sortAscending[SortType.SORT_DATE] ?: SortType.SORT_DATE.isDefaultAscending + } else { + sortType = K9.sortType + sortAscending = K9.isSortAscending(sortType) + sortDateAscending = K9.isSortAscending(SortType.SORT_DATE) + } + } + + protected fun loadMessageList(forceUpdate: Boolean = false) { + val config = MessageListConfig( + localSearch, + showingThreadedList, + sortType, + sortAscending, + sortDateAscending, + activeMessage, + viewModel.messageSortOverrides.toMap(), + ) + + if (forceUpdate) { + accounts = config.search.getLegacyAccounts(accountManager) + } + + viewModel.loadMessageList(config, forceUpdate) + } + + fun folderLoading(folderId: Long, loading: Boolean) { + currentFolder?.let { + if (it.databaseId == folderId) { + it.loading = loading + updateFooterText() + } + } + } + + fun updateTitle() { + if (error != null) { + fragmentListener.setMessageListTitle(getString(R.string.message_list_error_title)) + return + } else if (!isInitialized) { + return + } + + setWindowTitle() + + if (!localSearch.isManualSearch) { + setWindowProgress() + } + } + + private fun setWindowProgress() { + var level = 0 + if (currentFolder?.loading == true) { + val folderTotal = activityListener.getFolderTotal() + if (folderTotal > 0) { + level = (MAX_PROGRESS * activityListener.getFolderCompleted() / folderTotal).coerceAtMost(MAX_PROGRESS) + } + } + + fragmentListener.setMessageListProgress(level) + } + + private fun setWindowTitle() { + val title = when { + isUnifiedFolders -> getString(R.string.integrated_inbox_title) + isNewMessagesView -> getString(R.string.new_messages_title) + isManualSearch -> getString(R.string.search_results) + isThreadDisplay -> threadTitle ?: "" + isSingleFolderMode -> currentFolder!!.displayName + else -> "" + } + + val subtitle = account.let { account -> + if (account == null || isUnifiedFolders || accountManager.getAccounts().size == 1) { + null + } else { + account.profile.name + } + } + + fragmentListener.setMessageListTitle(title, subtitle) + } + + fun progress(progress: Boolean) { + if (!progress) { + swipeRefreshLayout?.isRefreshing = false + } + + fragmentListener.setMessageListProgressEnabled(progress) + } + + override fun onFooterClicked() { + val account = this.account ?: return + val currentFolder = this.currentFolder ?: return + + if (currentFolder.moreMessages && !localSearch.isManualSearch) { + val folderId = currentFolder.databaseId + messagingController.loadMoreMessages(account.id, folderId) + } else if (isRemoteSearch) { + val additionalSearchResults = extraSearchResults ?: return + if (additionalSearchResults.isEmpty()) return + + val loadSearchResults: List + + val limit = account.remoteSearchNumResults + if (limit in 1 until additionalSearchResults.size) { + extraSearchResults = additionalSearchResults.subList(limit, additionalSearchResults.size) + loadSearchResults = additionalSearchResults.subList(0, limit) + } else { + extraSearchResults = null + loadSearchResults = additionalSearchResults + updateFooterText(null) + } + + messagingController.loadSearchResults( + account.id, + currentFolder.databaseId, + loadSearchResults, + activityListener, + ) + } + } + + override fun onMessageClicked(messageListItem: MessageListItem) { + if (!isActive) { + // Ignore click events that are delivered after the Fragment is no longer active. This could happen when + // the user taps two messages at almost the same time and the first tap opens a new MessageListFragment. + return + } + + val clickTime = SystemClock.elapsedRealtime() + if (clickTime - lastMessageClick < MINIMUM_CLICK_INTERVAL) return + + if (adapter.selectedCount > 0) { + toggleMessageSelect(messageListItem) + } else { + lastMessageClick = clickTime + if (showingThreadedList && messageListItem.threadCount > 1) { + fragmentListener.showThread(messageListItem.account, messageListItem.threadRoot) + } else { + openMessage(messageListItem.messageReference) + } + } + } + + override fun onDestroyView() { + coordinatorLayout = null + recyclerView = null + messageListSwipeCallback = null + itemTouchHelper = null + swipeRefreshLayout = null + floatingActionButton = null + + if (isNewMessagesView && !requireActivity().isChangingConfigurations) { + account?.id?.let { messagingController.clearNewMessages(it) } + } + + super.onDestroyView() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + if (error != null) return + + outState.putLongArray(STATE_SELECTED_MESSAGES, adapter.selected.toLongArray()) + outState.putBoolean(STATE_REMOTE_SEARCH_PERFORMED, isRemoteSearch) + outState.putStringArray( + STATE_ACTIVE_MESSAGES, + activeMessages?.map(MessageReference::toIdentityString)?.toTypedArray(), + ) + if (activeMessage != null) { + outState.putString(STATE_ACTIVE_MESSAGE, activeMessage!!.toIdentityString()) + } + } + + protected open val messageListAppearance: MessageListAppearance + get() = MessageListAppearance( + fontSizes = K9.fontSizes, + previewLines = messageListSettings.previewLines, + stars = !isOutbox && generalSettingsManager.getConfig().display.inboxSettings.isShowMessageListStars, + senderAboveSubject = generalSettingsManager + .getConfig() + .display + .inboxSettings + .isMessageListSenderAboveSubject, + showContactPicture = messageListSettings.isShowContactPicture, + showingThreadedList = showingThreadedList, + backGroundAsReadIndicator = messageListSettings.isUseBackgroundAsUnreadIndicator, + showAccountIndicator = isShowAccountIndicator, + density = messageListSettings.uiDensity, + ) + + private fun getFolderInfoHolder(account: LegacyAccount, folderId: Long): FolderInfoHolder { + val localStore = localStoreProvider.getInstanceByLegacyAccount(account) + val localFolder = localStore.getFolder(folderId) + localFolder.open() + return FolderInfoHolder(folderNameFormatter, outboxFolderManager, localFolder, account) + } + + override fun onResume() { + super.onResume() + + if (hasConnectivity == null) { + hasConnectivity = connectivityManager.isNetworkAvailable() + } + + messagingControllerRegistry.addListener(activityListener) + + updateTitle() + } + + override fun onPause() { + super.onPause() + + messagingControllerRegistry.removeListener(activityListener) + } + + private fun goBack() { + fragmentListener.goBack() + } + + fun onCompose() { + if (!isSingleAccountMode) { + fragmentListener.onCompose(null) + } else { + fragmentListener.onCompose(account) + } + } + + private fun changeSort(sortType: SortType) { + val sortAscending = if (this.sortType == sortType) !sortAscending else null + changeSort(sortType, sortAscending) + } + + private fun onRemoteSearchRequested() { + val folderId = currentFolder!!.databaseId + val queryString = localSearch.remoteSearchArguments + + isRemoteSearch = true + swipeRefreshLayout?.isEnabled = false + + val account = this.account ?: return + + remoteSearchFuture = messagingController.searchRemoteMessages( + account.id, + folderId, + queryString, + null, + null, + activityListener, + ) + + invalidateMenu() + } + + /** + * Change the sort type and sort order used for the message list. + * + * @param sortType Specifies which field to use for sorting the message list. + * @param sortAscending Specifies the sort order. If this argument is `null` the default search order for the + * sort type is used. + */ + // FIXME: Don't save the changes in the UI thread + private fun changeSort(sortType: SortType, sortAscending: Boolean?) { + this.sortType = sortType + val account = this.account + if (account != null) { + val resolvedAscending = sortAscending ?: (account.sortAscending[sortType] ?: sortType.isDefaultAscending) + this.sortAscending = resolvedAscending + + val newSortAscendingMap = account.sortAscending.toMutableMap().apply { + this[sortType] = resolvedAscending + } + + this.sortDateAscending = newSortAscendingMap[SortType.SORT_DATE] ?: SortType.SORT_DATE.isDefaultAscending + + val updatedAccount = account.copy( + sortType = sortType, + sortAscending = newSortAscendingMap, + ) + lifecycleScope.launch(Dispatchers.IO) { + accountManager.saveAccount(updatedAccount) + this@AbstractMessageListFragment.account = updatedAccount + } + } else { + K9.sortType = this.sortType + if (sortAscending == null) { + this.sortAscending = K9.isSortAscending(this.sortType) + } else { + this.sortAscending = sortAscending + } + K9.setSortAscending(this.sortType, this.sortAscending) + sortDateAscending = K9.isSortAscending(SortType.SORT_DATE) + + K9.saveSettingsAsync() + } + + reSort() + } + + private fun reSort() { + val toastString = sortTypeToastProvider.getToast(sortType, sortAscending) + Toast.makeText(activity, toastString, Toast.LENGTH_SHORT).show() + loadMessageList() + } + + fun onCycleSort() { + val sortTypes = SortType.entries + val currentIndex = sortTypes.indexOf(sortType) + val newIndex = if (currentIndex == sortTypes.lastIndex) 0 else currentIndex + 1 + val nextSortType = sortTypes[newIndex] + changeSort(nextSortType) + } + + private fun onDelete(messages: List) { + if (interactionSettings.isConfirmDelete) { + // remember the message selection for #onCreateDialog(int) + activeMessages = messages + showDialog(R.id.dialog_confirm_delete) + } else { + onDeleteConfirmed(messages) + } + } + + private fun onDeleteConfirmed(messages: List) { + if (showingThreadedList) { + messagingController.deleteThreads(messages) + } else { + messagingController.deleteMessages(messages) + } + } + + private fun onExpunge() { + currentFolder?.let { folderInfoHolder -> + account?.id?.let { messagingController.expunge(it, folderInfoHolder.databaseId) } + } + } + + private fun onEmptySpam() { + if (isShowingSpamFolder) { + showDialog(R.id.dialog_confirm_empty_spam) + } + } + + private val isShowingSpamFolder: Boolean + get() { + if (!isSingleFolderMode) return false + return currentFolder!!.databaseId == account!!.spamFolderId + } + + private fun onEmptyTrash() { + if (isShowingTrashFolder) { + showDialog(R.id.dialog_confirm_empty_trash) + } + } + + private val isShowingTrashFolder: Boolean + get() { + if (!isSingleFolderMode) return false + return currentFolder!!.databaseId == account!!.trashFolderId + } + + private fun showDialog(dialogId: Int) { + val dialogFragment = when (dialogId) { + R.id.dialog_confirm_spam -> { + val title = getString(R.string.dialog_confirm_spam_title) + val selectionSize = activeMessages!!.size + val message = resources.getQuantityString( + R.plurals.dialog_confirm_spam_message, + selectionSize, + selectionSize, + ) + val confirmText = getString(R.string.dialog_confirm_spam_confirm_button) + val cancelText = getString(R.string.dialog_confirm_spam_cancel_button) + ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) + } + + R.id.dialog_confirm_delete -> { + val title = getString(R.string.dialog_confirm_delete_title) + val selectionSize = activeMessages!!.size + val message = resources.getQuantityString( + R.plurals.dialog_confirm_delete_messages, + selectionSize, + selectionSize, + ) + val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) + val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) + ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) + } + + R.id.dialog_confirm_mark_all_as_read -> { + val title = getString(R.string.dialog_confirm_mark_all_as_read_title) + val message = getString(R.string.dialog_confirm_mark_all_as_read_message) + val confirmText = getString(R.string.dialog_confirm_mark_all_as_read_confirm_button) + val cancelText = getString(R.string.dialog_confirm_mark_all_as_read_cancel_button) + ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) + } + + R.id.dialog_confirm_empty_spam -> { + val title = getString(R.string.dialog_confirm_empty_spam_title) + val message = getString(R.string.dialog_confirm_empty_spam_message) + val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) + val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) + ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) + } + + R.id.dialog_confirm_empty_trash -> { + val title = getString(R.string.dialog_confirm_empty_trash_title) + val message = getString(R.string.dialog_confirm_empty_trash_message) + val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) + val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) + ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) + } + + else -> { + throw RuntimeException("Called showDialog(int) with unknown dialog id.") + } + } + + dialogFragment.setTargetFragment(this, dialogId) + dialogFragment.show(parentFragmentManager, getDialogTag(dialogId)) + } + + private fun getDialogTag(dialogId: Int): String { + return "dialog-$dialogId" + } + + override fun onPrepareOptionsMenu(menu: Menu) { + if (isActive && error == null) { + prepareMenu(menu) + } else { + hideMenu(menu) + } + } + + private fun prepareMenu(menu: Menu) { + menu.findItem(R.id.compose).isVisible = !isShowFloatingActionButton + menu.findItem(R.id.set_sort).isVisible = true + menu.findItem(R.id.select_all).isVisible = true + menu.findItem(R.id.mark_all_as_read).isVisible = isMarkAllAsReadSupported + menu.findItem(R.id.empty_spam).isVisible = isShowingSpamFolder + menu.findItem(R.id.empty_trash).isVisible = isShowingTrashFolder + + if (isSingleAccountMode) { + menu.findItem(R.id.send_messages).isVisible = isOutbox + menu.findItem(R.id.expunge).isVisible = isRemoteFolder && shouldShowExpungeAction() + } else { + menu.findItem(R.id.send_messages).isVisible = false + menu.findItem(R.id.expunge).isVisible = false + } + + menu.findItem(R.id.search).isVisible = !isManualSearch + menu.findItem(R.id.search_remote).isVisible = !isRemoteSearch && isRemoteSearchAllowed + menu.findItem(R.id.search_everywhere).isVisible = isManualSearch && !localSearch.searchAllAccounts() + // Show debug actions only in DEBUG builds and when account uses OAuth. + val isOAuthAccount = account?.incomingServerSettings?.authenticationType == AuthType.XOAUTH2 + val showDebug = BuildConfig.DEBUG && isOAuthAccount + menu.findItem(R.id.debug_invalidate_access_token_local).isVisible = showDebug + menu.findItem(R.id.debug_invalidate_access_token_server).isVisible = showDebug + menu.findItem(R.id.debug_force_auth_failure).isVisible = showDebug + menu.findItem(R.id.debug_feature_flags).isVisible = BuildConfig.DEBUG + } + + private fun hideMenu(menu: Menu) { + menu.findItem(R.id.compose).isVisible = false + menu.findItem(R.id.search).isVisible = false + menu.findItem(R.id.search_remote).isVisible = false + menu.findItem(R.id.set_sort).isVisible = false + menu.findItem(R.id.select_all).isVisible = false + menu.findItem(R.id.mark_all_as_read).isVisible = false + menu.findItem(R.id.send_messages).isVisible = false + menu.findItem(R.id.empty_spam).isVisible = false + menu.findItem(R.id.empty_trash).isVisible = false + menu.findItem(R.id.expunge).isVisible = false + menu.findItem(R.id.search_everywhere).isVisible = false + menu.findItem(R.id.debug_invalidate_access_token_local).isVisible = false + menu.findItem(R.id.debug_invalidate_access_token_server).isVisible = false + menu.findItem(R.id.debug_force_auth_failure).isVisible = false + menu.findItem(R.id.debug_feature_flags).isVisible = false + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + R.id.search_remote -> onRemoteSearch() + R.id.compose -> onCompose() + R.id.set_sort_date -> changeSort(SortType.SORT_DATE) + R.id.set_sort_arrival -> changeSort(SortType.SORT_ARRIVAL) + R.id.set_sort_subject -> changeSort(SortType.SORT_SUBJECT) + R.id.set_sort_sender -> changeSort(SortType.SORT_SENDER) + R.id.set_sort_flag -> changeSort(SortType.SORT_FLAGGED) + R.id.set_sort_unread -> changeSort(SortType.SORT_UNREAD) + R.id.set_sort_attach -> changeSort(SortType.SORT_ATTACHMENT) + R.id.select_all -> selectAll() + R.id.mark_all_as_read -> confirmMarkAllAsRead() + R.id.send_messages -> onSendPendingMessages() + R.id.empty_spam -> onEmptySpam() + R.id.empty_trash -> onEmptyTrash() + R.id.expunge -> onExpunge() + R.id.search_everywhere -> onSearchEverywhere() + R.id.debug_invalidate_access_token_local -> onDebugInvalidateAccessTokenLocal() + R.id.debug_invalidate_access_token_server -> onDebugInvalidateAccessTokenServer() + R.id.debug_force_auth_failure -> onDebugForceAuthFailure() + R.id.debug_feature_flags -> FeatureLauncherActivity.launch( + context = requireContext(), + target = FeatureLauncherTarget.SecretDebugSettingsFeatureFlag, + ) + + else -> return super.onOptionsItemSelected(item) + } + + return true + } + + private fun onSearchEverywhere() { + val searchQuery = requireActivity().intent.getStringExtra(SearchManager.QUERY) + + val searchIntent = Intent(requireContext(), Search::class.java).apply { + action = Intent.ACTION_SEARCH + putExtra(SearchManager.QUERY, searchQuery) + + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + + startActivity(searchIntent) + } + + private fun onSendPendingMessages() { + account?.id?.let { messagingController.sendPendingMessages(it, null) } + } + + private fun onDebugInvalidateAccessTokenServer() { + val uuid = account?.uuid + if (!BuildConfig.DEBUG || uuid == null) { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_unavailable, + Toast.LENGTH_SHORT, + ).show() + return + } + when (val outcome = authDebugActions.invalidateAccessTokenServer(uuid)) { + is Outcome.Success -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_server_done, + Toast.LENGTH_SHORT, + ).show() + } + + is Outcome.Failure -> { + when (outcome.error) { + is AuthDebugActions.Error.AccountNotFound, + is AuthDebugActions.Error.NoOAuthState, + -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_unavailable, + Toast.LENGTH_SHORT, + ).show() + } + + is AuthDebugActions.Error.CannotModifyAccessToken -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_cannot_modify, + Toast.LENGTH_SHORT, + ).show() + } + + is AuthDebugActions.Error.AlreadyModified -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_already_modified, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + private fun onDebugInvalidateAccessTokenLocal() { + val uuid = account?.uuid + if (!BuildConfig.DEBUG || uuid == null) { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_unavailable, + Toast.LENGTH_SHORT, + ).show() + return + } + when (val outcome = authDebugActions.invalidateAccessTokenLocal(uuid)) { + is Outcome.Success -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_local_done, + Toast.LENGTH_SHORT, + ).show() + } + + is Outcome.Failure -> { + when (outcome.error) { + is AuthDebugActions.Error.AccountNotFound, + is AuthDebugActions.Error.NoOAuthState, + is AuthDebugActions.Error.CannotModifyAccessToken, + is AuthDebugActions.Error.AlreadyModified, + -> { + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_unavailable, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + private fun onDebugForceAuthFailure() { + val uuid = account?.uuid + if (!BuildConfig.DEBUG || uuid == null) { + Toast.makeText(requireContext(), R.string.debug_force_auth_failure_unavailable, Toast.LENGTH_SHORT).show() + return + } + when (val outcome = authDebugActions.forceAuthFailure(uuid)) { + is Outcome.Success -> { + Toast.makeText(requireContext(), R.string.debug_force_auth_failure_done, Toast.LENGTH_SHORT).show() + } + + is Outcome.Failure -> { + when (outcome.error) { + is AuthDebugActions.Error.AccountNotFound -> Toast.makeText( + requireContext(), + R.string.debug_force_auth_failure_unavailable, + Toast.LENGTH_SHORT, + ).show() + + is AuthDebugActions.Error.NoOAuthState -> { + // Clearing is already the desired state; still report done so user knows it's in effect + Toast.makeText( + requireContext(), + R.string.debug_force_auth_failure_done, + Toast.LENGTH_SHORT, + ).show() + } + + is AuthDebugActions.Error.CannotModifyAccessToken, + is AuthDebugActions.Error.AlreadyModified, + -> { + // Not relevant to this action, but keep exhaustive when; show generic unavailable + Toast.makeText( + requireContext(), + R.string.debug_invalidate_access_token_unavailable, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + private fun updateFooterText() { + val currentFolder = this.currentFolder + val account = this.account + + val footerText = if (initialMessageListLoad) { + null + } else if (localSearch.isManualSearch || currentFolder == null || account == null) { + null + } else if (currentFolder.loading) { + getString(R.string.status_loading_more) + } else if (!currentFolder.moreMessages) { + null + } else if (account.displayCount == 0) { + getString(R.string.message_list_load_more_messages_action) + } else { + getString(R.string.load_more_messages_fmt, account.displayCount) + } + + updateFooterText(footerText) + } + + fun updateFooterText(text: String?) { + val currentItems = adapter + .viewItems + .filter { it !is MessageListViewItem.Footer } + .toMutableList() + + if (!text.isNullOrEmpty()) { + currentItems.add(MessageListViewItem.Footer(text)) + } + + adapter.viewItems = currentItems + } + + private fun selectAll() { + if (adapter.viewItems.isEmpty()) { + // Nothing to do if there are no messages + return + } + + adapter.selectAll() + + if (actionMode == null) { + startAndPrepareActionMode() + } + + computeBatchDirection() + updateActionMode() + } + + private fun toggleMessageSelect(messageListItem: MessageListItem) { + adapter.toggleSelection(messageListItem) + updateAfterSelectionChange() + } + + private fun selectMessage(messageListItem: MessageListItem) { + adapter.selectMessage(messageListItem) + updateAfterSelectionChange() + } + + private fun deselectMessage(messageListItem: MessageListItem) { + adapter.deselectMessage(messageListItem) + updateAfterSelectionChange() + } + + private fun isMessageSelected(messageListItem: MessageListItem): Boolean { + return adapter.isSelected(messageListItem) + } + + private fun updateAfterSelectionChange() { + if (adapter.selectedCount == 0) { + actionMode?.finish() + actionMode = null + return + } + + if (actionMode == null) { + startAndPrepareActionMode() + } + + computeBatchDirection() + updateActionMode() + } + + override fun onToggleMessageSelection(item: MessageListItem) { + toggleMessageSelect(item) + } + + override fun onToggleMessageFlag(item: MessageListItem) { + setFlag(item, Flag.FLAGGED, !item.isStarred) + } + + private fun updateActionMode() { + val actionMode = actionMode ?: error("actionMode == null") + actionMode.title = getString(R.string.actionbar_selected, adapter.selectedCount) + actionModeCallback.showSelectAll(!adapter.isAllSelected) + + actionMode.invalidate() + } + + private fun computeBatchDirection() { + val selectedMessages = adapter.selectedMessages + val notAllRead = !selectedMessages.all { it.isRead } + val notAllStarred = !selectedMessages.all { it.isStarred } + + actionModeCallback.showMarkAsRead(notAllRead) + actionModeCallback.showFlag(notAllStarred) + } + + private fun setFlag(messageListItem: MessageListItem, flag: Flag, newState: Boolean) { + val account = messageListItem.account + if (showingThreadedList && messageListItem.threadCount > 1) { + val threadRootId = messageListItem.threadRoot + messagingController.setFlagForThreads(account.id, listOf(threadRootId), flag, newState) + } else { + val messageId = messageListItem.databaseId + messagingController.setFlag(account.id, listOf(messageId), flag, newState) + } + + computeBatchDirection() + } + + private fun setFlagForSelected(flag: Flag, newState: Boolean) { + if (adapter.selected.isEmpty()) return + + val messageMap = mutableMapOf>() + val threadMap = mutableMapOf>() + val accounts = mutableSetOf() + + for (messageListItem in adapter.selectedMessages) { + val account = messageListItem.account + accounts.add(account) + + if (showingThreadedList && messageListItem.threadCount > 1) { + val threadRootIdList = threadMap.getOrPut(account) { mutableListOf() } + threadRootIdList.add(messageListItem.threadRoot) + } else { + val messageIdList = messageMap.getOrPut(account) { mutableListOf() } + messageIdList.add(messageListItem.databaseId) + } + } + + for (account in accounts) { + messageMap[account]?.let { messageIds -> + messagingController.setFlag(account.id, messageIds, flag, newState) + } + + threadMap[account]?.let { threadRootIds -> + messagingController.setFlagForThreads(account.id, threadRootIds, flag, newState) + } + } + + computeBatchDirection() + } + + private fun onMove(message: MessageReference) { + onMove(listOf(message)) + } + + private fun onMove(messages: List) { + if (!checkCopyOrMovePossible(messages, FolderOperation.MOVE)) return + + val folderId = when { + isThreadDisplay -> messages.first().folderId + isSingleFolderMode -> currentFolder!!.databaseId + else -> null + } + + displayFolderChoice( + operation = FolderOperation.MOVE, + sourceFolderId = folderId, + accountUuid = messages.first().accountUuid, + lastSelectedFolderId = null, + messages = messages, + ) + } + + private fun onCopy(message: MessageReference) { + onCopy(listOf(message)) + } + + private fun onCopy(messages: List) { + if (!checkCopyOrMovePossible(messages, FolderOperation.COPY)) return + + val folderId = when { + isThreadDisplay -> messages.first().folderId + isSingleFolderMode -> currentFolder!!.databaseId + else -> null + } + + displayFolderChoice( + operation = FolderOperation.COPY, + sourceFolderId = folderId, + accountUuid = messages.first().accountUuid, + lastSelectedFolderId = null, + messages = messages, + ) + } + + private fun displayFolderChoice( + operation: FolderOperation, + sourceFolderId: Long?, + accountUuid: String, + lastSelectedFolderId: Long?, + messages: List, + ) { + // Remember the selected messages so they are available in the registerForActivityResult() callbacks + activeMessages = messages + + val input = ChooseFolderResultContract.Input( + accountUuid = accountUuid, + currentFolderId = sourceFolderId, + scrollToFolderId = lastSelectedFolderId, + ) + when (operation) { + FolderOperation.COPY -> chooseFolderForCopyLauncher.launch(input) + FolderOperation.MOVE -> chooseFolderForMoveLauncher.launch(input) + } + } + + private fun handleChooseFolderResult( + result: ChooseFolderResultContract.Result?, + action: (Long, List) -> Unit, + ) { + if (result == null) return + + val destinationFolderId = result.folderId + val messages = activeMessages!! + + if (destinationFolderId != -1L) { + activeMessages = null + + if (messages.isNotEmpty()) { + setLastSelectedFolder(messages, destinationFolderId) + } + + action(destinationFolderId, messages) + } + } + + private fun setLastSelectedFolder(messages: List, folderId: Long) { + val firstMessage = messages.firstOrNull() ?: return + val account = accountManager.getAccount(firstMessage.accountUuid) ?: return + accountManager.saveAccount( + account.copy( + lastSelectedFolderId = folderId, + ), + ) + } + + private fun onArchive(message: MessageReference) { + onArchive(listOf(message)) + } + + private fun onArchive(messages: List) { + if (!checkCopyOrMovePossible(messages, FolderOperation.MOVE)) return + + if (showingThreadedList) { + messagingController.archiveThreads(messages) + } else { + messagingController.archiveMessages(messages) + } + } + + private fun groupMessagesByAccount( + messages: List, + ): Map> { + return messages.groupBy { accountManager.getAccount(it.accountUuid)!! } + } + + private fun onSpam(messages: List) { + if (interactionSettings.isConfirmSpam) { + // remember the message selection for #onCreateDialog(int) + activeMessages = messages + showDialog(R.id.dialog_confirm_spam) + } else { + onSpamConfirmed(messages) + } + } + + private fun onSpamConfirmed(messages: List) { + for ((account, messagesInAccount) in groupMessagesByAccount(messages)) { + account.spamFolderId?.let { spamFolderId -> + move(messagesInAccount, spamFolderId) + } + } + } + + private fun checkCopyOrMovePossible(messages: List, operation: FolderOperation): Boolean { + if (messages.isEmpty()) return false + + val account = accountManager.getAccount(messages.first().accountUuid) ?: return false + if (operation == FolderOperation.MOVE && + !messagingController.isMoveCapable(account.id) || + operation == FolderOperation.COPY && + !messagingController.isCopyCapable(account.id) + ) { + return false + } + + for (message in messages) { + if (operation == FolderOperation.MOVE && + !messagingController.isMoveCapable(message) || + operation == FolderOperation.COPY && + !messagingController.isCopyCapable(message) + ) { + val toast = Toast.makeText( + activity, + R.string.move_copy_cannot_copy_unsynced_message, + Toast.LENGTH_LONG, + ) + toast.show() + return false + } + } + + return true + } + + private fun copy(messages: List, folderId: Long) { + copyOrMove(messages, folderId, FolderOperation.COPY) + } + + private fun move(messages: List, folderId: Long) { + copyOrMove(messages, folderId, FolderOperation.MOVE) + } + + private fun copyOrMove(messages: List, destinationFolderId: Long, operation: FolderOperation) { + if (!checkCopyOrMovePossible(messages, operation)) return + + val folderMap = messages.asSequence() + .filterNot { it.folderId == destinationFolderId } + .groupBy { it.folderId } + + for ((folderId, messagesInFolder) in folderMap) { + val account = accountManager.getAccount(messagesInFolder.first().accountUuid) + if (account == null) { + logger.debug(logTag) { + "Account for message ${messagesInFolder.first()} not found, skipping copy/move operation" + } + continue + } + + when (operation) { + FolderOperation.MOVE if showingThreadedList -> { + messagingController.moveMessagesInThread( + account.id, + folderId, + messagesInFolder, + destinationFolderId, + ) + } + + FolderOperation.MOVE -> { + messagingController.moveMessages( + account.id, + folderId, + messagesInFolder, + destinationFolderId, + ) + } + + FolderOperation.COPY if showingThreadedList -> { + messagingController.copyMessagesInThread( + account.id, + folderId, + messagesInFolder, + destinationFolderId, + ) + } + + FolderOperation.COPY -> { + messagingController.copyMessages( + account.id, + folderId, + messagesInFolder, + destinationFolderId, + ) + } + } + } + } + + private fun onMoveToDraftsFolder(messages: List) { + account?.id?.let { messagingController.moveToDraftsFolder(it, currentFolder!!.databaseId, messages) } + activeMessages = null + } + + override fun doPositiveClick(dialogId: Int) { + when (dialogId) { + R.id.dialog_confirm_spam -> { + onSpamConfirmed(activeMessages!!) + activeMessages = null + } + + R.id.dialog_confirm_delete -> { + onDeleteConfirmed(activeMessages!!) + activeMessage = null + adapter.activeMessage = null + } + + R.id.dialog_confirm_mark_all_as_read -> { + markAllAsRead() + } + + R.id.dialog_confirm_empty_spam -> { + account?.id?.let { messagingController.emptySpam(it) } + } + + R.id.dialog_confirm_empty_trash -> { + account?.id?.let { messagingController.emptyTrash(it) } + } + } + } + + override fun doNegativeClick(dialogId: Int) { + if (dialogId == R.id.dialog_confirm_spam || dialogId == R.id.dialog_confirm_delete) { + val activeMessages = this.activeMessages ?: return + if (activeMessages.size == 1) { + // List item might have been swiped and is still showing the "swipe action background" + resetSwipedView(activeMessages.first()) + } + + this.activeMessages = null + } + } + + private fun resetSwipedView(messageReference: MessageReference) { + val recyclerView = this.recyclerView ?: return + val itemTouchHelper = this.itemTouchHelper ?: return + + adapter.getItem(messageReference)?.let { messageListItem -> + recyclerView.findViewHolderForItemId(messageListItem.uniqueId)?.let { viewHolder -> + itemTouchHelper.stopSwipe(viewHolder) + notifyItemChanged(messageListItem) + } + } + } + + override fun dialogCancelled(dialogId: Int) { + doNegativeClick(dialogId) + } + + private fun checkMail() { + if (isSingleAccountMode && isSingleFolderMode) { + val folderId = currentFolder!!.databaseId + account?.id?.let { messagingController.synchronizeMailbox(it, folderId, false, activityListener) } + account?.id?.let { messagingController.sendPendingMessages(it, activityListener) } + } else if (allAccounts) { + messagingController.checkMail(null, true, true, false, activityListener) + } else { + for (accountUuid in accountUuids) { + val account = accountManager.getAccount(accountUuid) + account?.id?.let { messagingController.checkMail(it, true, true, false, activityListener) } + } + } + } + + override fun onStop() { + // If we represent a remote search, then kill that before going back. + if (isRemoteSearch && remoteSearchFuture != null) { + try { + logger.info(logTag) { "Remote search in progress, attempting to abort..." } + + // Canceling the future stops any message fetches in progress. + val cancelSuccess = remoteSearchFuture!!.cancel(true) // mayInterruptIfRunning = true + if (!cancelSuccess) { + logger.error(logTag) { "Could not cancel remote search future." } + } + + // Closing the folder will kill off the connection if we're mid-search. + val searchAccount = account!! + + // Send a remoteSearchFinished() message for good measure. + activityListener.remoteSearchFinished( + currentFolder!!.databaseId, + 0, + searchAccount.remoteSearchNumResults, + null, + ) + } catch (e: Exception) { + // Since the user is going back, log and squash any exceptions. + logger.error(logTag, e) { "Could not abort remote search before going back" } + } + } + + super.onStop() + } + + fun openMessage(messageReference: MessageReference) { + fragmentListener.openMessage(messageReference) + } + + fun onReverseSort() { + changeSort(sortType) + } + + private val selectedMessage: MessageReference? + get() = selectedMessageListItem?.messageReference + + private val selectedMessageListItem: MessageListItem? + get() { + val recyclerView = recyclerView ?: return null + val focusedView = recyclerView.focusedChild ?: return null + val viewHolder = recyclerView.findContainingViewHolder(focusedView) as? MessageViewHolder ?: return null + return adapter.getItemById(viewHolder.uniqueId) + } + + private val selectedMessages: List + get() = adapter.selectedMessages.map { it.messageReference } + + fun onDelete() { + selectedMessage?.let { message -> + onDelete(listOf(message)) + } + } + + fun toggleMessageSelect() { + selectedMessageListItem?.let { messageListItem -> + toggleMessageSelect(messageListItem) + } + } + + fun onToggleFlagged() { + selectedMessageListItem?.let { messageListItem -> + setFlag(messageListItem, Flag.FLAGGED, !messageListItem.isStarred) + } + } + + fun onToggleRead() { + selectedMessageListItem?.let { messageListItem -> + setFlag(messageListItem, Flag.SEEN, !messageListItem.isRead) + } + } + + fun onMove() { + selectedMessage?.let { message -> + onMove(message) + } + } + + fun onArchive() { + selectedMessage?.let { message -> + onArchive(message) + } + } + + fun onCopy() { + selectedMessage?.let { message -> + onCopy(message) + } + } + + val isOutbox: Boolean + get() = isSpecialFolder(account?.id?.let(outboxFolderManager::getOutboxFolderIdSync)) + + private val isInbox: Boolean + get() = isSpecialFolder(account?.inboxFolderId) + + private val isArchiveFolder: Boolean + get() = isSpecialFolder(account?.archiveFolderId) + + private val isSpamFolder: Boolean + get() = isSpecialFolder(account?.spamFolderId) + + private fun isSpecialFolder(specialFolderId: Long?): Boolean { + val folderId = specialFolderId ?: return false + val currentFolder = currentFolder ?: return false + return currentFolder.databaseId == folderId + } + + private val isRemoteFolder: Boolean + get() { + if (localSearch.isManualSearch || isOutbox) return false + + val accountId = account?.id + return if (accountId == null || !messagingController.isMoveCapable(accountId)) { + // For POP3 accounts only the Inbox is a remote folder. + isInbox + } else { + true + } + } + + private val isManualSearch: Boolean + get() = localSearch.isManualSearch + + private fun shouldShowExpungeAction(): Boolean { + val account = this.account ?: return false + return account.expungePolicy == Expunge.EXPUNGE_MANUALLY && messagingController.supportsExpunge(account.id) + } + + private fun onRemoteSearch() { + // Remote search is useless without the network. + if (hasConnectivity == true) { + onRemoteSearchRequested() + } else { + Toast.makeText(activity, getText(R.string.remote_search_unavailable_no_network), Toast.LENGTH_SHORT).show() + } + } + + private val isRemoteSearchAllowed: Boolean + get() = isManualSearch && + !isRemoteSearch && + isSingleFolderMode && + (account?.id?.let { messagingController.isPushCapable(it) } == true) + + fun onSearchRequested(query: String): Boolean { + val folderId = currentFolder?.databaseId + return fragmentListener.startSearch(query, account, folderId) + } + + private fun setMessageList(messageListInfo: MessageListInfo) { + val messageListItems = messageListInfo.messageListItems + if (isThreadDisplay && messageListItems.isEmpty()) { + goBack() + return + } + + swipeRefreshLayout?.let { swipeRefreshLayout -> + swipeRefreshLayout.isRefreshing = false + swipeRefreshLayout.isEnabled = isPullToRefreshAllowed + } + + if (isThreadDisplay) { + if (messageListItems.isNotEmpty()) { + val strippedSubject = messageListItems.first().subject?.let { Utility.stripSubject(it) } + threadTitle = if (strippedSubject.isNullOrEmpty()) { + getString(R.string.general_no_subject) + } else { + strippedSubject + } + updateTitle() + } else { + // TODO: empty thread view -> return to full message list + } + } + + adapter.viewItems = buildList { + if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications).isEnabled()) { + add(MessageListViewItem.InAppNotificationBannerList) + } + addAll(messageListItems.map { MessageListViewItem.Message(it) }) + } + + rememberedSelected?.let { + rememberedSelected = null + adapter.restoreSelected(it) + } + + messageListItems + .map { it.account } + .toSet() + .forEach { account -> messagingController.checkAuthenticationProblem(account.id) } + + resetActionMode() + computeBatchDirection() + + invalidateMenu() + + initialMessageListLoad = false + + currentFolder?.let { currentFolder -> + currentFolder.moreMessages = messageListInfo.hasMoreMessages + updateFooterText() + } + } + + private fun resetActionMode() { + if (!isResumed) return + + if (!isActive || adapter.selected.isEmpty()) { + actionMode?.finish() + actionMode = null + return + } + + if (actionMode == null) { + startAndPrepareActionMode() + } + + updateActionMode() + } + + private fun startAndPrepareActionMode() { + actionMode = fragmentListener.startSupportActionMode(actionModeCallback) + actionMode?.invalidate() + } + + fun finishActionMode() { + actionMode?.finish() + } + + fun remoteSearchFinished() { + remoteSearchFuture = null + } + + fun setActiveMessage(messageReference: MessageReference?) { + activeMessage = messageReference + + rememberSortOverride(messageReference) + + // Reload message list with modified query that always includes the active message + if (isAdded) { + loadMessageList() + } + + // Redraw list immediately + if (::adapter.isInitialized) { + adapter.activeMessage = activeMessage + + if (messageReference != null) { + scrollToMessage(messageReference) + } + } + } + + fun onFullyActive() { + maybeShowFloatingActionButton() + } + + private fun maybeShowFloatingActionButton() { + floatingActionButton?.isVisible = true + } + + private fun maybeHideFloatingActionButton() { + floatingActionButton?.isGone = true + } + + // For the last N displayed messages we remember the original 'read' and 'starred' state of the messages. We pass + // this information to MessageListLoader so messages can be sorted according to these remembered values and not the + // current state. This way messages, that are marked as read/unread or starred/not starred while being displayed, + // won't immediately change position in the message list if the list is sorted by these fields. + // The main benefit is that the swipe to next/previous message feature will work in a less surprising way. + private fun rememberSortOverride(messageReference: MessageReference?) { + val messageSortOverrides = viewModel.messageSortOverrides + + if (messageReference == null) { + messageSortOverrides.clear() + return + } + + if (sortType != SortType.SORT_UNREAD && sortType != SortType.SORT_FLAGGED) return + + val messageListItem = adapter.getItem(messageReference) ?: return + + val existingEntry = messageSortOverrides.firstOrNull { it.first == messageReference } + if (existingEntry != null) { + messageSortOverrides.remove(existingEntry) + messageSortOverrides.addLast(existingEntry) + } else { + messageSortOverrides.addLast( + messageReference to MessageSortOverride(messageListItem.isRead, messageListItem.isStarred), + ) + if (messageSortOverrides.size > MAXIMUM_MESSAGE_SORT_OVERRIDES) { + messageSortOverrides.removeFirst() + } + } + } + + private fun scrollToMessage(messageReference: MessageReference) { + val recyclerView = recyclerView ?: return + val messageListItem = adapter.getItem(messageReference) ?: return + val position = adapter.getPosition(messageListItem) ?: return + + val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager + val firstVisiblePosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() + val lastVisiblePosition = linearLayoutManager.findLastCompletelyVisibleItemPosition() + if (position !in firstVisiblePosition..lastVisiblePosition) { + recyclerView.smoothScrollToPosition(position) + } + } + + private val isMarkAllAsReadSupported: Boolean + get() = isSingleAccountMode && isSingleFolderMode && !isOutbox + + private fun confirmMarkAllAsRead() { + if (interactionSettings.isConfirmMarkAllRead) { + showDialog(R.id.dialog_confirm_mark_all_as_read) + } else { + markAllAsRead() + } + } + + private fun markAllAsRead() { + if (isMarkAllAsReadSupported) { + account?.id?.let { messagingController.markAllMessagesRead(it, currentFolder!!.databaseId) } + } + } + + private fun invalidateMenu() { + activity?.invalidateMenu() + } + + private val isCheckMailSupported: Boolean + get() = allAccounts || !isSingleAccountMode || !isSingleFolderMode || isRemoteFolder + + private val isCheckMailAllowed: Boolean + get() = !isManualSearch && isCheckMailSupported + + private val isPullToRefreshAllowed: Boolean + get() = isRemoteSearchAllowed || isCheckMailAllowed + + private var itemSelectedOnSwipeStart = false + + private val swipeListener = object : MessageListSwipeListener { + override fun onSwipeStarted(item: MessageListItem, action: SwipeAction) { + swipeRefreshLayout?.isEnabled = false + itemSelectedOnSwipeStart = isMessageSelected(item) + if (itemSelectedOnSwipeStart && action != SwipeAction.ToggleSelection) { + deselectMessage(item) + } + } + + override fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction) { + if (action == SwipeAction.ToggleSelection) { + if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { + selectMessage(item) + } + } else if (isMessageSelected(item)) { + deselectMessage(item) + } + } + + override fun onSwipeAction(item: MessageListItem, action: SwipeAction) { + if (action.removesItem || action == SwipeAction.ToggleSelection) { + itemSelectedOnSwipeStart = false + } + + when (action) { + SwipeAction.None -> Unit + SwipeAction.ToggleSelection -> { + toggleMessageSelect(item) + } + + SwipeAction.ToggleRead -> { + setFlag(item, Flag.SEEN, !item.isRead) + } + + SwipeAction.ToggleStar -> { + setFlag(item, Flag.FLAGGED, !item.isStarred) + } + + SwipeAction.ArchiveDisabled -> + Snackbar + .make( + requireNotNull(view), + R.string.archiving_not_available_for_this_account, + Snackbar.LENGTH_LONG, + ) + .show() + + SwipeAction.ArchiveSetupArchiveFolder -> setupArchiveFolderDialogFragmentFactory.show( + accountUuid = item.account.uuid, + fragmentManager = parentFragmentManager, + ) + + SwipeAction.Archive -> { + onArchive(item.messageReference) + } + + SwipeAction.Delete -> { + onDelete(listOf(item.messageReference)) + } + + SwipeAction.Spam -> { + onSpam(listOf(item.messageReference)) + } + + SwipeAction.Move -> { + val messageReference = item.messageReference + resetSwipedView(messageReference) + onMove(messageReference) + } + } + } + + override fun onSwipeEnded(item: MessageListItem) { + swipeRefreshLayout?.isEnabled = true + if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { + selectMessage(item) + } + } + } + + private fun notifyItemChanged(item: MessageListItem) { + val position = adapter.getPosition(item) ?: return + adapter.notifyItemChanged(position) + } + + private val swipeActionSupportProvider = SwipeActionSupportProvider { item, action -> + when (action) { + SwipeAction.None -> false + SwipeAction.ToggleSelection -> true + SwipeAction.ToggleRead -> !isOutbox + SwipeAction.ToggleStar -> !isOutbox + SwipeAction.Archive, SwipeAction.ArchiveDisabled, SwipeAction.ArchiveSetupArchiveFolder -> { + !isOutbox && item.folderId != item.account.archiveFolderId + } + + SwipeAction.Delete -> true + SwipeAction.Move -> !isOutbox && messagingController.isMoveCapable(item.account.id) + SwipeAction.Spam -> !isOutbox && item.account.hasSpamFolder() && item.folderId != item.account.spamFolderId + } + } + + override fun filterInAppNotificationEvents(notification: InAppNotification): Boolean { + val accountUuid = notification.accountUuid + return notification !is SentFolderNotFoundNotification && + accountUuid != null && + accountUuid in accountUuids + } + + override fun onNotificationActionClicked(action: NotificationAction) = onNotificationActionClick(action) + + override fun onNotificationActionClick(action: NotificationAction) { + when (action) { + is NotificationAction.UpdateIncomingServerSettings -> + FeatureLauncherActivity.launch( + context = requireContext(), + target = FeatureLauncherTarget.AccountEditIncomingSettings(action.accountUuid), + ) + + is NotificationAction.UpdateOutgoingServerSettings -> + FeatureLauncherActivity.launch( + context = requireContext(), + target = FeatureLauncherTarget.AccountEditOutgoingSettings(action.accountUuid), + ) + + is NotificationAction.OpenNotificationCentre -> + errorNotificationsDialogFragmentFactory.show(fragmentManager = childFragmentManager) + + else -> Unit + } + } + + internal inner class MessageListActivityListener : SimpleMessagingListener() { + private val lock = Any() + + @GuardedBy("lock") + private var folderCompleted = 0 + + @GuardedBy("lock") + private var folderTotal = 0 + + override fun remoteSearchFailed(folderServerId: String?, err: String?) { + handler.post { + activity?.let { activity -> + Toast.makeText(activity, R.string.remote_search_error, Toast.LENGTH_LONG).show() + } + } + } + + override fun remoteSearchStarted(folderId: Long) { + handler.progress(true) + handler.updateFooter(getString(R.string.remote_search_sending_query)) + } + + override fun enableProgressIndicator(enable: Boolean) { + handler.progress(enable) + } + + override fun remoteSearchFinished( + folderId: Long, + numResults: Int, + maxResults: Int, + extraResults: List?, + ) { + handler.progress(false) + handler.remoteSearchFinished() + + extraSearchResults = extraResults + if (extraResults != null && extraResults.isNotEmpty()) { + handler.updateFooter(String.format(getString(R.string.load_more_messages_fmt), maxResults)) + } else { + handler.updateFooter(null) + } + } + + override fun remoteSearchServerQueryComplete(folderId: Long, numResults: Int, maxResults: Int) { + handler.progress(true) + + val footerText = if (maxResults != 0 && numResults > maxResults) { + resources.getQuantityString( + R.plurals.remote_search_downloading_limited, + maxResults, + maxResults, + numResults, + ) + } else { + resources.getQuantityString(R.plurals.remote_search_downloading, numResults, numResults) + } + + handler.updateFooter(footerText) + informUserOfStatus() + } + + private fun informUserOfStatus() { + handler.refreshTitle() + } + + override fun synchronizeMailboxStarted(account: LegacyAccountDto, folderId: Long) { + if (updateForMe(account, folderId)) { + handler.progress(true) + handler.folderLoading(folderId, true) + + synchronized(lock) { + folderCompleted = 0 + folderTotal = 0 + } + + informUserOfStatus() + } + } + + override fun synchronizeMailboxHeadersProgress( + account: LegacyAccountDto, + folderServerId: String, + completed: Int, + total: Int, + ) { + synchronized(lock) { + folderCompleted = completed + folderTotal = total + } + + informUserOfStatus() + } + + override fun synchronizeMailboxHeadersFinished( + account: LegacyAccountDto, + folderServerId: String, + total: Int, + completed: Int, + ) { + synchronized(lock) { + folderCompleted = 0 + folderTotal = 0 + } + + informUserOfStatus() + } + + override fun synchronizeMailboxProgress(account: LegacyAccountDto, folderId: Long, completed: Int, total: Int) { + synchronized(lock) { + folderCompleted = completed + folderTotal = total + } + + informUserOfStatus() + } + + override fun synchronizeMailboxFinished(account: LegacyAccountDto, folderId: Long) { + if (updateForMe(account, folderId)) { + handler.progress(false) + handler.folderLoading(folderId, false) + } + } + + override fun synchronizeMailboxFailed(account: LegacyAccountDto, folderId: Long, message: String) { + if (updateForMe(account, folderId)) { + handler.progress(false) + handler.folderLoading(folderId, false) + } + } + + override fun checkMailFinished(context: Context?, account: LegacyAccountDto?) { + handler.progress(false) + } + + private fun updateForMe(account: LegacyAccountDto?, folderId: Long): Boolean { + if (account == null || account.uuid !in accountUuids) return false + + val folderIds = localSearch.folderIds + return folderIds.isEmpty() || folderId in folderIds + } + + fun getFolderCompleted(): Int { + synchronized(lock) { + return folderCompleted + } + } + + fun getFolderTotal(): Int { + synchronized(lock) { + return folderTotal + } + } + } + + internal inner class ActionModeCallback : ActionMode.Callback { + private var selectAll: MenuItem? = null + private var markAsRead: MenuItem? = null + private var markAsUnread: MenuItem? = null + private var flag: MenuItem? = null + private var unflag: MenuItem? = null + private var disableMarkAsRead = false + private var disableFlag = false + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + selectAll = menu.findItem(R.id.select_all) + markAsRead = menu.findItem(R.id.mark_as_read) + markAsUnread = menu.findItem(R.id.mark_as_unread) + flag = menu.findItem(R.id.flag) + unflag = menu.findItem(R.id.unflag) + + // we don't support cross account actions atm + if (!isSingleAccountMode) { + val accounts = accountUuidsForSelected.mapNotNull { accountUuid -> + accountManager.getAccount(accountUuid) + } + + menu.findItem(R.id.move).isVisible = true + menu.findItem(R.id.copy).isVisible = true + + // Disable archive/spam options here and maybe enable below when checking account capabilities + menu.findItem(R.id.archive).isVisible = false + menu.findItem(R.id.spam).isVisible = false + + for (account in accounts) { + setContextCapabilities(account, menu) + } + } + + return true + } + + private val accountUuidsForSelected: Set + get() = adapter.selectedMessages.mapToSet { it.account.uuid } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + selectAll = null + markAsRead = null + markAsUnread = null + flag = null + unflag = null + + adapter.clearSelected() + } + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.menuInflater.inflate(R.menu.message_list_context_menu, menu) + + setContextCapabilities(account, menu) + return true + } + + private fun setContextCapabilities(account: LegacyAccount?, menu: Menu) { + if (!isSingleAccountMode || account == null) { + // We don't support cross-account copy/move operations right now + menu.findItem(R.id.move).isVisible = false + menu.findItem(R.id.copy).isVisible = false + + if (account?.hasArchiveFolder() == true) { + menu.findItem(R.id.archive).isVisible = true + } + + if (account?.hasSpamFolder() == true) { + menu.findItem(R.id.spam).isVisible = true + } + } else if (isOutbox) { + menu.findItem(R.id.mark_as_read).isVisible = false + menu.findItem(R.id.mark_as_unread).isVisible = false + menu.findItem(R.id.archive).isVisible = false + menu.findItem(R.id.copy).isVisible = false + menu.findItem(R.id.flag).isVisible = false + menu.findItem(R.id.unflag).isVisible = false + menu.findItem(R.id.spam).isVisible = false + menu.findItem(R.id.move).isVisible = false + + disableMarkAsRead = true + disableFlag = true + + if (account.hasDraftsFolder()) { + menu.findItem(R.id.move_to_drafts).isVisible = true + } + } else { + if (!messagingController.isCopyCapable(account.id)) { + menu.findItem(R.id.copy).isVisible = false + } + + if (!messagingController.isMoveCapable(account.id)) { + menu.findItem(R.id.move).isVisible = false + menu.findItem(R.id.archive).isVisible = false + menu.findItem(R.id.spam).isVisible = false + } else { + if (!account.hasArchiveFolder() || isArchiveFolder) { + menu.findItem(R.id.archive).isVisible = false + } + + if (!account.hasSpamFolder() || isSpamFolder) { + menu.findItem(R.id.spam).isVisible = false + } + } + } + } + + fun showSelectAll(show: Boolean) { + selectAll?.isVisible = show + } + + fun showMarkAsRead(show: Boolean) { + if (!disableMarkAsRead) { + markAsRead?.isVisible = show + markAsUnread?.isVisible = !show + } + } + + fun showFlag(show: Boolean) { + if (!disableFlag) { + flag?.isVisible = show + unflag?.isVisible = !show + } + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + // In the following we assume that we can't move or copy mails to the same folder. Also that spam isn't + // available if we are in the spam folder, same for archive. + + val endSelectionMode = when (item.itemId) { + R.id.delete -> { + onDelete(selectedMessages) + true + } + + R.id.mark_as_read -> { + setFlagForSelected(Flag.SEEN, true) + false + } + + R.id.mark_as_unread -> { + setFlagForSelected(Flag.SEEN, false) + false + } + + R.id.flag -> { + setFlagForSelected(Flag.FLAGGED, true) + false + } + + R.id.unflag -> { + setFlagForSelected(Flag.FLAGGED, false) + false + } + + R.id.select_all -> { + selectAll() + false + } + + R.id.archive -> { + onArchive(selectedMessages) + // TODO: Only finish action mode if all messages have been moved. + true + } + + R.id.spam -> { + onSpam(selectedMessages) + // TODO: Only finish action mode if all messages have been moved. + true + } + + R.id.move -> { + onMove(selectedMessages) + true + } + + R.id.move_to_drafts -> { + onMoveToDraftsFolder(selectedMessages) + true + } + + R.id.copy -> { + onCopy(selectedMessages) + true + } + + else -> return false + } + + if (endSelectionMode) { + mode.finish() + } + + return true + } + } + + private enum class FolderOperation { + COPY, + MOVE, + } + + @Suppress("detekt.UnnecessaryAnnotationUseSiteTarget") // https://github.com/detekt/detekt/issues/8212 + protected enum class Error(@param:StringRes val errorText: Int) { + FolderNotFound(R.string.message_list_error_folder_not_found), + } + + interface MessageListFragmentListener { + fun setMessageListProgressEnabled(enable: Boolean) + fun setMessageListProgress(level: Int) + fun showThread(account: LegacyAccount, threadRootId: Long) + fun openMessage(messageReference: MessageReference) + fun setMessageListTitle(title: String, subtitle: String? = null) + fun onCompose(account: LegacyAccount?) + fun startSearch(query: String, account: LegacyAccount?, folderId: Long?): Boolean + fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? + fun goBack() + + companion object { + const val MAX_PROGRESS = 10000 + } + } + + interface Factory { + fun newInstance( + search: LocalMessageSearch, + isThreadDisplay: Boolean, + threadedList: Boolean, + ): AbstractMessageListFragment + } + + companion object { + protected const val ARG_SEARCH = "searchObject" + protected const val ARG_THREADED_LIST = "showingThreadedList" + protected const val ARG_IS_THREAD_DISPLAY = "isThreadedDisplay" + + protected const val STATE_SELECTED_MESSAGES = "selectedMessages" + protected const val STATE_ACTIVE_MESSAGES = "activeMessages" + protected const val STATE_ACTIVE_MESSAGE = "activeMessage" + protected const val STATE_REMOTE_SEARCH_PERFORMED = "remoteSearchPerformed" + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/LegacyMessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/LegacyMessageListFragment.kt new file mode 100644 index 00000000000..8846bb4120a --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/LegacyMessageListFragment.kt @@ -0,0 +1,29 @@ +package com.fsck.k9.ui.messagelist + +import androidx.core.os.bundleOf +import net.thunderbird.feature.search.legacy.LocalMessageSearch +import net.thunderbird.feature.search.legacy.serialization.LocalMessageSearchSerializer + +private const val TAG = "LegacyMessageListFragment" + +class LegacyMessageListFragment : AbstractMessageListFragment() { + override val logTag: String = TAG + + companion object Factory : AbstractMessageListFragment.Factory { + override fun newInstance( + search: LocalMessageSearch, + isThreadDisplay: Boolean, + threadedList: Boolean, + ): LegacyMessageListFragment { + val searchBytes = LocalMessageSearchSerializer.serialize(search) + + return LegacyMessageListFragment().apply { + arguments = bundleOf( + ARG_SEARCH to searchBytes, + ARG_IS_THREAD_DISPLAY to isThreadDisplay, + ARG_THREADED_LIST to threadedList, + ) + } + } + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 812511ffedf..30cfc0a7260 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -1,2613 +1,146 @@ package com.fsck.k9.ui.messagelist -import android.app.SearchManager -import android.content.Context -import android.content.Intent import android.os.Bundle -import android.os.SystemClock -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher -import androidx.annotation.StringRes -import androidx.appcompat.view.ActionMode -import androidx.compose.animation.animateContentSize -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.ComposeView -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.os.bundleOf -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsCompat.Type.systemBars -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.core.view.setPadding -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.setFragmentResultListener -import androidx.lifecycle.Observer +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.feature.launcher.FeatureLauncherActivity -import app.k9mail.feature.launcher.FeatureLauncherTarget -import app.k9mail.legacy.message.controller.MessageReference -import app.k9mail.legacy.message.controller.MessagingControllerRegistry -import app.k9mail.legacy.message.controller.SimpleMessagingListener -import app.k9mail.legacy.ui.folder.FolderNameFormatter -import app.k9mail.ui.utils.itemtouchhelper.ItemTouchHelper -import app.k9mail.ui.utils.linearlayoutmanager.LinearLayoutManager -import com.fsck.k9.K9 -import com.fsck.k9.activity.FolderInfoHolder -import com.fsck.k9.activity.Search -import com.fsck.k9.activity.misc.ContactPicture -import com.fsck.k9.controller.MessagingControllerWrapper -import com.fsck.k9.fragment.ConfirmationDialogFragment -import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener -import com.fsck.k9.helper.Utility -import com.fsck.k9.helper.mapToSet -import com.fsck.k9.mail.AuthType -import com.fsck.k9.mailstore.LocalStoreProvider -import com.fsck.k9.search.getLegacyAccounts -import com.fsck.k9.ui.BuildConfig -import com.fsck.k9.ui.R -import com.fsck.k9.ui.changelog.RecentChangesActivity -import com.fsck.k9.ui.changelog.RecentChangesViewModel -import com.fsck.k9.ui.choosefolder.ChooseFolderActivity -import com.fsck.k9.ui.choosefolder.ChooseFolderResultContract -import com.fsck.k9.ui.helper.RelativeDateTimeFormatter -import com.fsck.k9.ui.messagelist.MessageListFragment.MessageListFragmentListener.Companion.MAX_PROGRESS -import com.fsck.k9.ui.messagelist.debug.AuthDebugActions -import com.fsck.k9.ui.messagelist.item.MessageViewHolder -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.android.material.snackbar.BaseTransientBottomBar.BaseCallback -import com.google.android.material.snackbar.Snackbar -import com.google.android.material.textview.MaterialTextView -import java.util.concurrent.Future -import kotlin.time.Clock -import kotlin.time.ExperimentalTime -import kotlinx.collections.immutable.persistentSetOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import androidx.lifecycle.repeatOnLifecycle +import com.fsck.k9.FontSizes import kotlinx.coroutines.launch -import net.jcip.annotations.GuardedBy -import net.thunderbird.core.android.account.Expunge -import net.thunderbird.core.android.account.LegacyAccount -import net.thunderbird.core.android.account.LegacyAccountDto -import net.thunderbird.core.android.account.LegacyAccountManager -import net.thunderbird.core.android.account.SortType -import net.thunderbird.core.android.network.ConnectivityManager -import net.thunderbird.core.common.action.SwipeAction -import net.thunderbird.core.common.exception.MessagingException -import net.thunderbird.core.common.mail.Flag -import net.thunderbird.core.featureflag.FeatureFlagKey -import net.thunderbird.core.featureflag.FeatureFlagProvider -import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.logging.Logger -import net.thunderbird.core.logging.legacy.Log -import net.thunderbird.core.outcome.Outcome -import net.thunderbird.core.preference.GeneralSettingsManager -import net.thunderbird.core.preference.display.visualSettings.message.list.DisplayMessageListSettings -import net.thunderbird.core.preference.interaction.InteractionSettings -import net.thunderbird.core.ui.theme.api.FeatureThemeProvider -import net.thunderbird.feature.account.avatar.AvatarMonogramCreator -import net.thunderbird.feature.mail.folder.api.OutboxFolderManager -import net.thunderbird.feature.mail.message.list.domain.DomainContract -import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory -import net.thunderbird.feature.notification.api.content.InAppNotification -import net.thunderbird.feature.notification.api.content.SentFolderNotFoundNotification -import net.thunderbird.feature.notification.api.ui.InAppNotificationHost -import net.thunderbird.feature.notification.api.ui.action.NotificationAction -import net.thunderbird.feature.notification.api.ui.dialog.ErrorNotificationsDialogFragmentActionListener -import net.thunderbird.feature.notification.api.ui.dialog.ErrorNotificationsDialogFragmentFactory -import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag -import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual -import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.mail.message.list.extension.toDomainSortType +import net.thunderbird.feature.mail.message.list.extension.toSortType +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.MessageListContract +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.SortType import net.thunderbird.feature.search.legacy.LocalMessageSearch -import net.thunderbird.feature.search.legacy.SearchAccount import net.thunderbird.feature.search.legacy.serialization.LocalMessageSearchSerializer import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf - -private const val MAXIMUM_MESSAGE_SORT_OVERRIDES = 3 -private const val MINIMUM_CLICK_INTERVAL = 200L -private const val RECENT_CHANGES_SNACKBAR_DURATION = 10 * 1000 +import org.koin.core.parameter.parameterSetOf +import net.thunderbird.core.android.account.SortType as DomainSortType private const val TAG = "MessageListFragment" -@Suppress("LargeClass", "TooManyFunctions") -class MessageListFragment : - Fragment(), - ConfirmationDialogFragmentListener, - MessageListItemActionListener, - ErrorNotificationsDialogFragmentActionListener { - - val viewModel: MessageListViewModel by viewModel() - private val recentChangesViewModel: RecentChangesViewModel by viewModel() - - private val generalSettingsManager: GeneralSettingsManager by inject() - private val sortTypeToastProvider: SortTypeToastProvider by inject() - private val folderNameFormatter: FolderNameFormatter by inject { parametersOf(requireContext()) } - private val messagingController: MessagingControllerWrapper by inject() - private val messagingControllerRegistry: MessagingControllerRegistry by inject() - private val accountManager: LegacyAccountManager by inject() - private val connectivityManager: ConnectivityManager by inject() - private val localStoreProvider: LocalStoreProvider by inject() - - @OptIn(ExperimentalTime::class) - private val clock: Clock by inject() - private val setupArchiveFolderDialogFragmentFactory: SetupArchiveFolderDialogFragmentFactory by inject() - private val buildSwipeActions: DomainContract.UseCase.BuildSwipeActions by inject() - private val featureFlagProvider: FeatureFlagProvider by inject() - private val featureThemeProvider: FeatureThemeProvider by inject() +// TODO: Move this fragment to :feature:mail:message:list once all migration to the new +// MessageListFragment to MVI is done. +class MessageListFragment : AbstractMessageListFragment() { + override val logTag: String = TAG private val logger: Logger by inject() - private val outboxFolderManager: OutboxFolderManager by inject() - private val authDebugActions: AuthDebugActions by inject() - private val errorNotificationsDialogFragmentFactory: ErrorNotificationsDialogFragmentFactory by inject() - - private val handler = MessageListHandler(this) - private val activityListener = MessageListActivityListener() - private val actionModeCallback = ActionModeCallback() - - private val contactRepository: ContactRepository by inject() - private val avatarMonogramCreator: AvatarMonogramCreator by inject() - - private val chooseFolderForMoveLauncher: ActivityResultLauncher = - registerForActivityResult(ChooseFolderResultContract(ChooseFolderActivity.Action.MOVE)) { result -> - handleChooseFolderResult(result) { folderId, messages -> - move(messages, folderId) - } - } - private val chooseFolderForCopyLauncher: ActivityResultLauncher = - registerForActivityResult(ChooseFolderResultContract(ChooseFolderActivity.Action.COPY)) { result -> - handleChooseFolderResult(result) { folderId, messages -> - copy(messages, folderId) - } - } - - private lateinit var fragmentListener: MessageListFragmentListener - - private lateinit var recentChangesSnackbar: Snackbar - private var coordinatorLayout: CoordinatorLayout? = null - private var recyclerView: RecyclerView? = null - private var itemTouchHelper: ItemTouchHelper? = null - private var swipeRefreshLayout: SwipeRefreshLayout? = null - private var floatingActionButton: FloatingActionButton? = null - - private lateinit var adapter: MessageListAdapter - - private lateinit var accountUuids: Array - private var accounts: List = emptyList() - - private var account: LegacyAccount? = null - - private var currentFolder: FolderInfoHolder? = null - private var remoteSearchFuture: Future<*>? = null - private var extraSearchResults: List? = null - private var threadTitle: String? = null - private var allAccounts = false - private var sortType = SortType.SORT_DATE - private var sortAscending = true - private var sortDateAscending = false - private var actionMode: ActionMode? = null - private var hasConnectivity: Boolean? = null - private var isShowFloatingActionButton: Boolean = true - - /** - * Relevant messages for the current context when we have to remember the chosen messages - * between user interactions (e.g. selecting a folder for move operation). - */ - private var activeMessages: List? = null - private var showingThreadedList = false - private var isThreadDisplay = false - private var activeMessage: MessageReference? = null - private var rememberedSelected: Set? = null - private var lastMessageClick = 0L - - lateinit var localSearch: LocalMessageSearch - private set - var isSingleAccountMode = false - private set - private var isSingleFolderMode = false - private var isRemoteSearch = false - private var initialMessageListLoad = true - - private val isUnifiedFolders: Boolean - get() = localSearch.id == SearchAccount.UNIFIED_FOLDERS - - private val isNewMessagesView: Boolean - get() = localSearch.id == SearchAccount.NEW_MESSAGES - - /** - * `true` after [.onCreate] was executed. Used in [.updateTitle] to - * make sure we don't access member variables before initialization is complete. - */ - private var isInitialized = false - - private var error: Error? = null - - private var messageListSwipeCallback: MessageListSwipeCallback? = null - private val interactionSettings: InteractionSettings - get() = generalSettingsManager.getConfig().interaction - private val messageListSettings: DisplayMessageListSettings - get() = generalSettingsManager.getConfig().display.visualSettings.messageListSettings - - /** - * Set this to `true` when the fragment should be considered active. When active, the fragment adds its actions to - * the toolbar. When inactive, the fragment won't add its actions to the toolbar, even it is still visible, e.g. as - * part of an animation. - */ - var isActive: Boolean = false - set(value) { - field = value - resetActionMode() - invalidateMenu() - maybeHideFloatingActionButton() - } - - val isShowAccountIndicator: Boolean - get() = isUnifiedFolders || !isSingleAccountMode - - override fun onAttach(context: Context) { - super.onAttach(context) - - fragmentListener = try { - context as MessageListFragmentListener - } catch (e: ClassCastException) { - error("${context.javaClass} must implement MessageListFragmentListener") - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setHasOptionsMenu(true) - - restoreInstanceState(savedInstanceState) - val error = decodeArguments() - if (error != null) { - this.error = error - return - } - - viewModel.getMessageListLiveData().observe(this) { messageListInfo: MessageListInfo -> - setMessageList(messageListInfo) - } - - adapter = createMessageListAdapter() - - generalSettingsManager.getSettingsFlow() - /** - * Skips the first emitted item from the settings flow, - * since the initial value of `showingThreadedList` is taken - * from the fragment's arguments rather than the flow. - */ - .drop(1) - .map { it.display.inboxSettings.isThreadedViewEnabled } - .distinctUntilChanged() - .onEach { - showingThreadedList = it - loadMessageList(forceUpdate = true) - } - .launchIn(lifecycleScope) - - isInitialized = true - } - - private fun restoreInstanceState(savedInstanceState: Bundle?) { - if (savedInstanceState == null) return - - activeMessages = savedInstanceState.getStringArray(STATE_ACTIVE_MESSAGES) - ?.map { MessageReference.parse(it)!! } - restoreSelectedMessages(savedInstanceState) - isRemoteSearch = savedInstanceState.getBoolean(STATE_REMOTE_SEARCH_PERFORMED) - val messageReferenceString = savedInstanceState.getString(STATE_ACTIVE_MESSAGE) - activeMessage = MessageReference.parse(messageReferenceString) - } - - private fun restoreSelectedMessages(savedInstanceState: Bundle) { - rememberedSelected = savedInstanceState.getLongArray(STATE_SELECTED_MESSAGES)?.toSet() + private val viewModel: MessageListContract.ViewModel by inject { + decodeArguments() + parameterSetOf(accountUuids.map { AccountIdFactory.of(it) }.toSet()) } - private fun decodeArguments(): Error? { - val arguments = requireArguments() - showingThreadedList = arguments.getBoolean(ARG_THREADED_LIST, false) - isThreadDisplay = arguments.getBoolean(ARG_IS_THREAD_DISPLAY, false) - - localSearch = arguments.getByteArray(ARG_SEARCH)?.let { - LocalMessageSearchSerializer.deserialize(it) - }!! - - allAccounts = localSearch.searchAllAccounts() - val searchAccounts = localSearch.getLegacyAccounts(accountManager).also { - accounts = it - } - if (searchAccounts.size == 1) { - isSingleAccountMode = true - val singleAccount = searchAccounts[0] - account = singleAccount - accountUuids = arrayOf(singleAccount.uuid) - } else { - isSingleAccountMode = false - account = null - accountUuids = searchAccounts.map { it.uuid }.toTypedArray() + private val selectedSortType: SortType? + get() { + val key = if (isSingleAccountMode) account?.id else null + val state = viewModel.state.value + return state.selectedSortTypes[key] } - isSingleFolderMode = false - if (isSingleAccountMode && localSearch.folderIds.size == 1) { - try { - val account = checkNotNull(account) - val folderId = localSearch.folderIds[0] - currentFolder = getFolderInfoHolder(account, folderId) - isSingleFolderMode = true - } catch (e: MessagingException) { - return Error.FolderNotFound + override var sortType: DomainSortType + get() = selectedSortType + ?.toDomainSortType() + ?.first + .also { + logger.debug(TAG) { "Selected sort type = $it" } } + ?: DomainSortType.SORT_DATE + set(value) { + // We still allow the sortType to be set as we didn't migrate the message loading yet. + // Once it is migrated, we should override the `changeSort(sortType: SortType)` and + // `changeSort(sortType: SortType, sortAscending: Boolean?)` methods. + // The next line will only update the value of the `selectedSortTypes` to reflect the new + // selection. + viewModel.event( + event = MessageListEvent.ChangeSortType( + accountId = account?.id, + sortType = value.toSortType( + isAscending = account?.sortAscending[value] ?: value.isDefaultAscending, + ), + ), + ) } - return null - } - - private fun createMessageListAdapter(): MessageListAdapter { - @OptIn(ExperimentalTime::class) - return MessageListAdapter( - theme = requireActivity().theme, - res = resources, - layoutInflater = layoutInflater, - contactsPictureLoader = ContactPicture.getContactPictureLoader(), - listItemListener = this, - appearance = messageListAppearance, - relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock), - themeProvider = featureThemeProvider, - featureFlagProvider = featureFlagProvider, - contactRepository = contactRepository, - avatarMonogramCreator = avatarMonogramCreator, - ).apply { - activeMessage = this@MessageListFragment.activeMessage - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return if (error == null) { - inflater.inflate(R.layout.message_list_fragment, container, false).also { view -> - setFragmentResultListener( - SetupArchiveFolderDialogFragmentFactory.RESULT_CODE_DISMISS_REQUEST_KEY, - ) { key, bundle -> - Log.d( - "SetupArchiveFolderDialogFragment fragment listener triggered with " + - "key: $key and bundle: $bundle", - ) - loadMessageList(forceUpdate = true) + override var sortAscending: Boolean + get() = selectedSortType + ?.toDomainSortType() + ?.second + .also { + logger.debug(TAG) { "Selected sort type = $it" } + } ?: false + set(value) { + val newSort = when (selectedSortType) { + SortType.DateAsc if !value -> SortType.DateDesc + SortType.DateDesc if value -> SortType.DateAsc + SortType.ArrivalAsc if !value -> SortType.ArrivalDesc + SortType.ArrivalDesc if value -> SortType.ArrivalAsc + SortType.SenderAsc if !value -> SortType.SenderDesc + SortType.SenderDesc if value -> SortType.SenderAsc + SortType.UnreadAsc if !value -> SortType.UnreadDesc + SortType.UnreadDesc if value -> SortType.UnreadAsc + SortType.FlaggedAsc if !value -> SortType.FlaggedDesc + SortType.FlaggedDesc if value -> SortType.FlaggedAsc + SortType.AttachmentAsc if !value -> SortType.AttachmentDesc + SortType.AttachmentDesc if value -> SortType.AttachmentAsc + SortType.SubjectAsc if !value -> SortType.SubjectDesc + SortType.SubjectDesc if value -> SortType.SubjectAsc + else -> selectedSortType + } + newSort?.let { + viewModel.event(event = MessageListEvent.ChangeSortType(accountId = account?.id, sortType = it)) + } + } + + override val messageListAppearance: MessageListAppearance + get() { + return requireNotNull(viewModel.state.value.preferences) + .also { + logger.verbose(TAG) { "messageListAppearance.get() called with preferences = $it" } } - } - } else { - inflater.inflate(R.layout.message_list_error, container, false) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (error == null) { - initializeMessageListLayout(view) - } else { - initializeErrorLayout(view) - } - } - - private fun initializeErrorLayout(view: View) { - val errorMessageView = view.findViewById(R.id.message_list_error_message) - errorMessageView.text = getString(error!!.errorText) - } - - private fun initializeMessageListLayout(view: View) { - initializeSwipeRefreshLayout(view) - initializeFloatingActionButton(view) - initializeRecyclerView(view) - initializeRecentChangesSnackbar() - - // This needs to be done before loading the message list below - initializeSortSettings() - - loadMessageList() - } - - private fun initializeSwipeRefreshLayout(view: View) { - val swipeRefreshLayout = view.findViewById(R.id.swiperefresh) - - if (isRemoteSearchAllowed) { - swipeRefreshLayout.setOnRefreshListener { onRemoteSearchRequested() } - } else if (isCheckMailSupported) { - swipeRefreshLayout.setOnRefreshListener { checkMail() } - } - - // Disable pull-to-refresh until the message list has been loaded - swipeRefreshLayout.isEnabled = false - - this.swipeRefreshLayout = swipeRefreshLayout - } - - private fun initializeFloatingActionButton(view: View) { - isShowFloatingActionButton = generalSettingsManager.getConfig() - .display - .inboxSettings - .isShowComposeButtonOnMessageList - if (isShowFloatingActionButton) { - enableFloatingActionButton(view) - } else { - disableFloatingActionButton(view) - } - - initializeFloatingActionButtonInsets(view) - } - - private fun initializeFloatingActionButtonInsets(view: View) { - val floatingActionButton = view.findViewById(R.id.floating_action_button) - - ViewCompat.setOnApplyWindowInsetsListener(floatingActionButton) { v, windowInsets -> - val insets = windowInsets.getInsets(systemBars()) - - v.updateLayoutParams { - val fabMargin = view.resources.getDimensionPixelSize(R.dimen.floatingActionButtonMargin) - - bottomMargin = fabMargin - rightMargin = fabMargin + insets.right - leftMargin = fabMargin + insets.left - } - - windowInsets - } - } - - private fun enableFloatingActionButton(view: View) { - val floatingActionButton = view.findViewById(R.id.floating_action_button) - - ViewCompat.setOnApplyWindowInsetsListener(floatingActionButton) { view, windowInsets -> - val insets = windowInsets.getInsets(systemBars()) - val margin = resources.getDimensionPixelSize(R.dimen.floatingActionButtonMargin) - - view.updateLayoutParams { - leftMargin = margin + insets.left - bottomMargin = margin + insets.bottom - rightMargin = margin + insets.right - } - - WindowInsetsCompat.CONSUMED + .toMessageListAppearance() } - floatingActionButton.setOnClickListener { - onCompose() - } - - this.floatingActionButton = floatingActionButton - } - - private fun disableFloatingActionButton(view: View) { - val floatingActionButton = view.findViewById(R.id.floating_action_button) - floatingActionButton.isGone = true - } - - private fun initializeRecyclerView(view: View) { - val recyclerView = view.findViewById(R.id.message_list) - - if (!isShowFloatingActionButton) { - recyclerView.setPadding(0) - } - - recyclerView.layoutManager = LinearLayoutManager(requireContext()) - recyclerView.itemAnimator = MessageListItemAnimator() - - val itemTouchHelper = ItemTouchHelper( - MessageListSwipeCallback( - context = requireContext(), - scope = lifecycleScope, - resourceProvider = SwipeResourceProvider(requireContext()), - swipeActionSupportProvider = swipeActionSupportProvider, - buildSwipeActions = buildSwipeActions, - adapter = adapter, - listener = swipeListener, - accounts = accounts, - ).also { messageListSwipeCallback = it }, - ) - itemTouchHelper.attachToRecyclerView(recyclerView) - - recyclerView.adapter = adapter - - if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled) { - view.findViewById(R.id.banner_global_compose_view).apply { - setContent { - featureThemeProvider.WithTheme { - InAppNotificationHost( - onActionClick = { }, - enabled = persistentSetOf( - DisplayInAppNotificationFlag.BannerGlobalNotifications, - DisplayInAppNotificationFlag.SnackbarNotifications, - ), - onSnackbarNotificationEvent = ::onSnackbarInAppNotificationEvent, - eventFilter = ::filterInAppNotificationEvents, - modifier = Modifier - .animateContentSize() - .onSizeChanged { size -> - recyclerView.updatePadding(top = size.height) - }, - ) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.effect.collect { effect -> + when (effect) { + // TODO(#10251): Required as the current implementation of sortType and sortAscending + // returns null before we load the sort type. That should be removed when + // the message list item's load is switched to the new state. + is MessageListEffect.RefreshMessageList -> loadMessageList() + else -> Unit } } } } - - this.recyclerView = recyclerView - this.itemTouchHelper = itemTouchHelper - } - - private fun requireCoordinatorLayout(): CoordinatorLayout { - val coordinatorLayout = coordinatorLayout - ?: requireView().findViewById(R.id.message_list_coordinator) - .also { coordinatorLayout = it } - - return coordinatorLayout ?: error("Coordinator layout not initialized") - } - - private suspend fun onSnackbarInAppNotificationEvent(visual: SnackbarVisual) { - val (message, action, duration) = visual - Snackbar.make( - requireCoordinatorLayout(), - message, - when (duration) { - SnackbarDuration.Short -> Snackbar.LENGTH_SHORT - SnackbarDuration.Long -> Snackbar.LENGTH_LONG - SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE - }, - ).apply { - if (action != null) { - setAction( - action.resolveTitle(), - ) { - // TODO. - } - } - }.show() - } - - private val shouldShowRecentChangesHintObserver = Observer { showRecentChangesHint -> - val recentChangesSnackbarVisible = recentChangesSnackbar.isShown - if (showRecentChangesHint && !recentChangesSnackbarVisible) { - recentChangesSnackbar.show() - } else if (!showRecentChangesHint && recentChangesSnackbarVisible) { - recentChangesSnackbar.dismiss() - } - } - - private fun initializeRecentChangesSnackbar() { - val coordinatorLayout = requireCoordinatorLayout() - - recentChangesSnackbar = Snackbar - .make(coordinatorLayout, R.string.changelog_snackbar_text, RECENT_CHANGES_SNACKBAR_DURATION) - .setAction(R.string.changelog_snackbar_button_text) { launchRecentChangesActivity() } - .addCallback( - object : BaseCallback() { - override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { - if (event == DISMISS_EVENT_SWIPE || event == DISMISS_EVENT_TIMEOUT) { - recentChangesViewModel.onRecentChangesHintDismissed() - } - } - }, - ) - - recentChangesViewModel.shouldShowRecentChangesHint - .observe(viewLifecycleOwner, shouldShowRecentChangesHintObserver) - } - - private fun launchRecentChangesActivity() { - recentChangesViewModel.shouldShowRecentChangesHint.removeObserver(shouldShowRecentChangesHintObserver) - - val intent = Intent(requireActivity(), RecentChangesActivity::class.java) - startActivity(intent) - } - - private fun initializeSortSettings() { - if (isSingleAccountMode) { - val account = checkNotNull(this.account) - sortType = account.sortType - sortAscending = account.sortAscending[sortType] ?: sortType.isDefaultAscending - sortDateAscending = account.sortAscending[SortType.SORT_DATE] ?: SortType.SORT_DATE.isDefaultAscending - } else { - sortType = K9.sortType - sortAscending = K9.isSortAscending(sortType) - sortDateAscending = K9.isSortAscending(SortType.SORT_DATE) - } - } - - private fun loadMessageList(forceUpdate: Boolean = false) { - val config = MessageListConfig( - localSearch, - showingThreadedList, - sortType, - sortAscending, - sortDateAscending, - activeMessage, - viewModel.messageSortOverrides.toMap(), - ) - - if (forceUpdate) { - accounts = config.search.getLegacyAccounts(accountManager) - } - - viewModel.loadMessageList(config, forceUpdate) - } - - fun folderLoading(folderId: Long, loading: Boolean) { - currentFolder?.let { - if (it.databaseId == folderId) { - it.loading = loading - updateFooterText() - } - } - } - - fun updateTitle() { - if (error != null) { - fragmentListener.setMessageListTitle(getString(R.string.message_list_error_title)) - return - } else if (!isInitialized) { - return - } - - setWindowTitle() - - if (!localSearch.isManualSearch) { - setWindowProgress() - } - } - - private fun setWindowProgress() { - var level = 0 - if (currentFolder?.loading == true) { - val folderTotal = activityListener.getFolderTotal() - if (folderTotal > 0) { - level = (MAX_PROGRESS * activityListener.getFolderCompleted() / folderTotal).coerceAtMost(MAX_PROGRESS) - } - } - - fragmentListener.setMessageListProgress(level) - } - - private fun setWindowTitle() { - val title = when { - isUnifiedFolders -> getString(R.string.integrated_inbox_title) - isNewMessagesView -> getString(R.string.new_messages_title) - isManualSearch -> getString(R.string.search_results) - isThreadDisplay -> threadTitle ?: "" - isSingleFolderMode -> currentFolder!!.displayName - else -> "" - } - - val subtitle = account.let { account -> - if (account == null || isUnifiedFolders || accountManager.getAccounts().size == 1) { - null - } else { - account.profile.name - } - } - - fragmentListener.setMessageListTitle(title, subtitle) - } - - fun progress(progress: Boolean) { - if (!progress) { - swipeRefreshLayout?.isRefreshing = false - } - - fragmentListener.setMessageListProgressEnabled(progress) - } - - override fun onFooterClicked() { - val account = this.account ?: return - val currentFolder = this.currentFolder ?: return - - if (currentFolder.moreMessages && !localSearch.isManualSearch) { - val folderId = currentFolder.databaseId - messagingController.loadMoreMessages(account.id, folderId) - } else if (isRemoteSearch) { - val additionalSearchResults = extraSearchResults ?: return - if (additionalSearchResults.isEmpty()) return - - val loadSearchResults: List - - val limit = account.remoteSearchNumResults - if (limit in 1 until additionalSearchResults.size) { - extraSearchResults = additionalSearchResults.subList(limit, additionalSearchResults.size) - loadSearchResults = additionalSearchResults.subList(0, limit) - } else { - extraSearchResults = null - loadSearchResults = additionalSearchResults - updateFooterText(null) - } - - messagingController.loadSearchResults( - account.id, - currentFolder.databaseId, - loadSearchResults, - activityListener, - ) - } - } - - override fun onMessageClicked(messageListItem: MessageListItem) { - if (!isActive) { - // Ignore click events that are delivered after the Fragment is no longer active. This could happen when - // the user taps two messages at almost the same time and the first tap opens a new MessageListFragment. - return - } - - val clickTime = SystemClock.elapsedRealtime() - if (clickTime - lastMessageClick < MINIMUM_CLICK_INTERVAL) return - - if (adapter.selectedCount > 0) { - toggleMessageSelect(messageListItem) - } else { - lastMessageClick = clickTime - if (showingThreadedList && messageListItem.threadCount > 1) { - fragmentListener.showThread(messageListItem.account, messageListItem.threadRoot) - } else { - openMessage(messageListItem.messageReference) - } - } - } - - override fun onDestroyView() { - coordinatorLayout = null - recyclerView = null - messageListSwipeCallback = null - itemTouchHelper = null - swipeRefreshLayout = null - floatingActionButton = null - - if (isNewMessagesView && !requireActivity().isChangingConfigurations) { - account?.id?.let { messagingController.clearNewMessages(it) } - } - - super.onDestroyView() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - if (error != null) return - - outState.putLongArray(STATE_SELECTED_MESSAGES, adapter.selected.toLongArray()) - outState.putBoolean(STATE_REMOTE_SEARCH_PERFORMED, isRemoteSearch) - outState.putStringArray( - STATE_ACTIVE_MESSAGES, - activeMessages?.map(MessageReference::toIdentityString)?.toTypedArray(), - ) - if (activeMessage != null) { - outState.putString(STATE_ACTIVE_MESSAGE, activeMessage!!.toIdentityString()) - } - } - - private val messageListAppearance: MessageListAppearance - get() = MessageListAppearance( - fontSizes = K9.fontSizes, - previewLines = messageListSettings.previewLines, - stars = !isOutbox && generalSettingsManager.getConfig().display.inboxSettings.isShowMessageListStars, - senderAboveSubject = generalSettingsManager - .getConfig() - .display - .inboxSettings - .isMessageListSenderAboveSubject, - showContactPicture = messageListSettings.isShowContactPicture, - showingThreadedList = showingThreadedList, - backGroundAsReadIndicator = messageListSettings.isUseBackgroundAsUnreadIndicator, - showAccountIndicator = isShowAccountIndicator, - density = messageListSettings.uiDensity, - ) - - private fun getFolderInfoHolder(account: LegacyAccount, folderId: Long): FolderInfoHolder { - val localStore = localStoreProvider.getInstanceByLegacyAccount(account) - val localFolder = localStore.getFolder(folderId) - localFolder.open() - return FolderInfoHolder(folderNameFormatter, outboxFolderManager, localFolder, account) - } - - override fun onResume() { - super.onResume() - - if (hasConnectivity == null) { - hasConnectivity = connectivityManager.isNetworkAvailable() - } - - messagingControllerRegistry.addListener(activityListener) - - updateTitle() - } - - override fun onPause() { - super.onPause() - - messagingControllerRegistry.removeListener(activityListener) - } - - private fun goBack() { - fragmentListener.goBack() - } - - fun onCompose() { - if (!isSingleAccountMode) { - fragmentListener.onCompose(null) - } else { - fragmentListener.onCompose(account) - } - } - - private fun changeSort(sortType: SortType) { - val sortAscending = if (this.sortType == sortType) !sortAscending else null - changeSort(sortType, sortAscending) - } - - private fun onRemoteSearchRequested() { - val folderId = currentFolder!!.databaseId - val queryString = localSearch.remoteSearchArguments - - isRemoteSearch = true - swipeRefreshLayout?.isEnabled = false - - val account = this.account ?: return - - remoteSearchFuture = messagingController.searchRemoteMessages( - account.id, - folderId, - queryString, - null, - null, - activityListener, - ) - - invalidateMenu() - } - - /** - * Change the sort type and sort order used for the message list. - * - * @param sortType Specifies which field to use for sorting the message list. - * @param sortAscending Specifies the sort order. If this argument is `null` the default search order for the - * sort type is used. - */ - // FIXME: Don't save the changes in the UI thread - private fun changeSort(sortType: SortType, sortAscending: Boolean?) { - this.sortType = sortType - val account = this.account - if (account != null) { - val resolvedAscending = sortAscending ?: (account.sortAscending[sortType] ?: sortType.isDefaultAscending) - this.sortAscending = resolvedAscending - - val newSortAscendingMap = account.sortAscending.toMutableMap().apply { - this[sortType] = resolvedAscending - } - - this.sortDateAscending = newSortAscendingMap[SortType.SORT_DATE] ?: SortType.SORT_DATE.isDefaultAscending - - val updatedAccount = account.copy( - sortType = sortType, - sortAscending = newSortAscendingMap, - ) - lifecycleScope.launch(Dispatchers.IO) { - accountManager.saveAccount(updatedAccount) - this@MessageListFragment.account = updatedAccount - } - } else { - K9.sortType = this.sortType - if (sortAscending == null) { - this.sortAscending = K9.isSortAscending(this.sortType) - } else { - this.sortAscending = sortAscending - } - K9.setSortAscending(this.sortType, this.sortAscending) - sortDateAscending = K9.isSortAscending(SortType.SORT_DATE) - - K9.saveSettingsAsync() - } - - reSort() - } - - private fun reSort() { - val toastString = sortTypeToastProvider.getToast(sortType, sortAscending) - Toast.makeText(activity, toastString, Toast.LENGTH_SHORT).show() - loadMessageList() - } - - fun onCycleSort() { - val sortTypes = SortType.entries - val currentIndex = sortTypes.indexOf(sortType) - val newIndex = if (currentIndex == sortTypes.lastIndex) 0 else currentIndex + 1 - val nextSortType = sortTypes[newIndex] - changeSort(nextSortType) - } - - private fun onDelete(messages: List) { - if (interactionSettings.isConfirmDelete) { - // remember the message selection for #onCreateDialog(int) - activeMessages = messages - showDialog(R.id.dialog_confirm_delete) - } else { - onDeleteConfirmed(messages) - } - } - - private fun onDeleteConfirmed(messages: List) { - if (showingThreadedList) { - messagingController.deleteThreads(messages) - } else { - messagingController.deleteMessages(messages) - } - } - - private fun onExpunge() { - currentFolder?.let { folderInfoHolder -> - account?.id?.let { messagingController.expunge(it, folderInfoHolder.databaseId) } - } } - private fun onEmptySpam() { - if (isShowingSpamFolder) { - showDialog(R.id.dialog_confirm_empty_spam) - } - } - - private val isShowingSpamFolder: Boolean - get() { - if (!isSingleFolderMode) return false - return currentFolder!!.databaseId == account!!.spamFolderId - } - - private fun onEmptyTrash() { - if (isShowingTrashFolder) { - showDialog(R.id.dialog_confirm_empty_trash) - } + override fun initializeSortSettings() { + // The sort type settings is now loaded by the GetMessageListPreferencesUseCase. + // Therefore, we override this method with an empty implementation, removing the + // legacy implementation. } - private val isShowingTrashFolder: Boolean - get() { - if (!isSingleFolderMode) return false - return currentFolder!!.databaseId == account!!.trashFolderId - } - - private fun showDialog(dialogId: Int) { - val dialogFragment = when (dialogId) { - R.id.dialog_confirm_spam -> { - val title = getString(R.string.dialog_confirm_spam_title) - val selectionSize = activeMessages!!.size - val message = resources.getQuantityString( - R.plurals.dialog_confirm_spam_message, - selectionSize, - selectionSize, - ) - val confirmText = getString(R.string.dialog_confirm_spam_confirm_button) - val cancelText = getString(R.string.dialog_confirm_spam_cancel_button) - ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) - } - - R.id.dialog_confirm_delete -> { - val title = getString(R.string.dialog_confirm_delete_title) - val selectionSize = activeMessages!!.size - val message = resources.getQuantityString( - R.plurals.dialog_confirm_delete_messages, - selectionSize, - selectionSize, - ) - val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) - val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) - ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) - } - - R.id.dialog_confirm_mark_all_as_read -> { - val title = getString(R.string.dialog_confirm_mark_all_as_read_title) - val message = getString(R.string.dialog_confirm_mark_all_as_read_message) - val confirmText = getString(R.string.dialog_confirm_mark_all_as_read_confirm_button) - val cancelText = getString(R.string.dialog_confirm_mark_all_as_read_cancel_button) - ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) - } - - R.id.dialog_confirm_empty_spam -> { - val title = getString(R.string.dialog_confirm_empty_spam_title) - val message = getString(R.string.dialog_confirm_empty_spam_message) - val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) - val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) - ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) - } - - R.id.dialog_confirm_empty_trash -> { - val title = getString(R.string.dialog_confirm_empty_trash_title) - val message = getString(R.string.dialog_confirm_empty_trash_message) - val confirmText = getString(R.string.dialog_confirm_delete_confirm_button) - val cancelText = getString(R.string.dialog_confirm_delete_cancel_button) - ConfirmationDialogFragment.newInstance(dialogId, title, message, confirmText, cancelText) - } - - else -> { - throw RuntimeException("Called showDialog(int) with unknown dialog id.") - } - } - - dialogFragment.setTargetFragment(this, dialogId) - dialogFragment.show(parentFragmentManager, getDialogTag(dialogId)) - } - - private fun getDialogTag(dialogId: Int): String { - return "dialog-$dialogId" - } - - override fun onPrepareOptionsMenu(menu: Menu) { - if (isActive && error == null) { - prepareMenu(menu) - } else { - hideMenu(menu) - } - } - - private fun prepareMenu(menu: Menu) { - menu.findItem(R.id.compose).isVisible = !isShowFloatingActionButton - menu.findItem(R.id.set_sort).isVisible = true - menu.findItem(R.id.select_all).isVisible = true - menu.findItem(R.id.mark_all_as_read).isVisible = isMarkAllAsReadSupported - menu.findItem(R.id.empty_spam).isVisible = isShowingSpamFolder - menu.findItem(R.id.empty_trash).isVisible = isShowingTrashFolder - - if (isSingleAccountMode) { - menu.findItem(R.id.send_messages).isVisible = isOutbox - menu.findItem(R.id.expunge).isVisible = isRemoteFolder && shouldShowExpungeAction() - } else { - menu.findItem(R.id.send_messages).isVisible = false - menu.findItem(R.id.expunge).isVisible = false - } - - menu.findItem(R.id.search).isVisible = !isManualSearch - menu.findItem(R.id.search_remote).isVisible = !isRemoteSearch && isRemoteSearchAllowed - menu.findItem(R.id.search_everywhere).isVisible = isManualSearch && !localSearch.searchAllAccounts() - // Show debug actions only in DEBUG builds and when account uses OAuth. - val isOAuthAccount = account?.incomingServerSettings?.authenticationType == AuthType.XOAUTH2 - val showDebug = BuildConfig.DEBUG && isOAuthAccount - menu.findItem(R.id.debug_invalidate_access_token_local).isVisible = showDebug - menu.findItem(R.id.debug_invalidate_access_token_server).isVisible = showDebug - menu.findItem(R.id.debug_force_auth_failure).isVisible = showDebug - menu.findItem(R.id.debug_feature_flags).isVisible = BuildConfig.DEBUG - } - - private fun hideMenu(menu: Menu) { - menu.findItem(R.id.compose).isVisible = false - menu.findItem(R.id.search).isVisible = false - menu.findItem(R.id.search_remote).isVisible = false - menu.findItem(R.id.set_sort).isVisible = false - menu.findItem(R.id.select_all).isVisible = false - menu.findItem(R.id.mark_all_as_read).isVisible = false - menu.findItem(R.id.send_messages).isVisible = false - menu.findItem(R.id.empty_spam).isVisible = false - menu.findItem(R.id.empty_trash).isVisible = false - menu.findItem(R.id.expunge).isVisible = false - menu.findItem(R.id.search_everywhere).isVisible = false - menu.findItem(R.id.debug_invalidate_access_token_local).isVisible = false - menu.findItem(R.id.debug_invalidate_access_token_server).isVisible = false - menu.findItem(R.id.debug_force_auth_failure).isVisible = false - menu.findItem(R.id.debug_feature_flags).isVisible = false - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.search_remote -> onRemoteSearch() - R.id.compose -> onCompose() - R.id.set_sort_date -> changeSort(SortType.SORT_DATE) - R.id.set_sort_arrival -> changeSort(SortType.SORT_ARRIVAL) - R.id.set_sort_subject -> changeSort(SortType.SORT_SUBJECT) - R.id.set_sort_sender -> changeSort(SortType.SORT_SENDER) - R.id.set_sort_flag -> changeSort(SortType.SORT_FLAGGED) - R.id.set_sort_unread -> changeSort(SortType.SORT_UNREAD) - R.id.set_sort_attach -> changeSort(SortType.SORT_ATTACHMENT) - R.id.select_all -> selectAll() - R.id.mark_all_as_read -> confirmMarkAllAsRead() - R.id.send_messages -> onSendPendingMessages() - R.id.empty_spam -> onEmptySpam() - R.id.empty_trash -> onEmptyTrash() - R.id.expunge -> onExpunge() - R.id.search_everywhere -> onSearchEverywhere() - R.id.debug_invalidate_access_token_local -> onDebugInvalidateAccessTokenLocal() - R.id.debug_invalidate_access_token_server -> onDebugInvalidateAccessTokenServer() - R.id.debug_force_auth_failure -> onDebugForceAuthFailure() - R.id.debug_feature_flags -> FeatureLauncherActivity.launch( - context = requireContext(), - target = FeatureLauncherTarget.SecretDebugSettingsFeatureFlag, - ) - - else -> return super.onOptionsItemSelected(item) - } - - return true - } - - private fun onSearchEverywhere() { - val searchQuery = requireActivity().intent.getStringExtra(SearchManager.QUERY) - - val searchIntent = Intent(requireContext(), Search::class.java).apply { - action = Intent.ACTION_SEARCH - putExtra(SearchManager.QUERY, searchQuery) - - addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - } - - startActivity(searchIntent) - } - - private fun onSendPendingMessages() { - account?.id?.let { messagingController.sendPendingMessages(it, null) } - } - - private fun onDebugInvalidateAccessTokenServer() { - val uuid = account?.uuid - if (!BuildConfig.DEBUG || uuid == null) { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_unavailable, - Toast.LENGTH_SHORT, - ).show() - return - } - when (val outcome = authDebugActions.invalidateAccessTokenServer(uuid)) { - is Outcome.Success -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_server_done, - Toast.LENGTH_SHORT, - ).show() - } - - is Outcome.Failure -> { - when (outcome.error) { - is AuthDebugActions.Error.AccountNotFound, - is AuthDebugActions.Error.NoOAuthState, - -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_unavailable, - Toast.LENGTH_SHORT, - ).show() - } - - is AuthDebugActions.Error.CannotModifyAccessToken -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_cannot_modify, - Toast.LENGTH_SHORT, - ).show() - } - - is AuthDebugActions.Error.AlreadyModified -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_already_modified, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - private fun onDebugInvalidateAccessTokenLocal() { - val uuid = account?.uuid - if (!BuildConfig.DEBUG || uuid == null) { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_unavailable, - Toast.LENGTH_SHORT, - ).show() - return - } - when (val outcome = authDebugActions.invalidateAccessTokenLocal(uuid)) { - is Outcome.Success -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_local_done, - Toast.LENGTH_SHORT, - ).show() - } - - is Outcome.Failure -> { - when (outcome.error) { - is AuthDebugActions.Error.AccountNotFound, - is AuthDebugActions.Error.NoOAuthState, - is AuthDebugActions.Error.CannotModifyAccessToken, - is AuthDebugActions.Error.AlreadyModified, - -> { - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_unavailable, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - private fun onDebugForceAuthFailure() { - val uuid = account?.uuid - if (!BuildConfig.DEBUG || uuid == null) { - Toast.makeText(requireContext(), R.string.debug_force_auth_failure_unavailable, Toast.LENGTH_SHORT).show() - return - } - when (val outcome = authDebugActions.forceAuthFailure(uuid)) { - is Outcome.Success -> { - Toast.makeText(requireContext(), R.string.debug_force_auth_failure_done, Toast.LENGTH_SHORT).show() - } - - is Outcome.Failure -> { - when (outcome.error) { - is AuthDebugActions.Error.AccountNotFound -> Toast.makeText( - requireContext(), - R.string.debug_force_auth_failure_unavailable, - Toast.LENGTH_SHORT, - ).show() - - is AuthDebugActions.Error.NoOAuthState -> { - // Clearing is already the desired state; still report done so user knows it's in effect - Toast.makeText( - requireContext(), - R.string.debug_force_auth_failure_done, - Toast.LENGTH_SHORT, - ).show() - } - - is AuthDebugActions.Error.CannotModifyAccessToken, - is AuthDebugActions.Error.AlreadyModified, - -> { - // Not relevant to this action, but keep exhaustive when; show generic unavailable - Toast.makeText( - requireContext(), - R.string.debug_invalidate_access_token_unavailable, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - } - - private fun updateFooterText() { - val currentFolder = this.currentFolder - val account = this.account - - val footerText = if (initialMessageListLoad) { - null - } else if (localSearch.isManualSearch || currentFolder == null || account == null) { - null - } else if (currentFolder.loading) { - getString(R.string.status_loading_more) - } else if (!currentFolder.moreMessages) { - null - } else if (account.displayCount == 0) { - getString(R.string.message_list_load_more_messages_action) - } else { - getString(R.string.load_more_messages_fmt, account.displayCount) - } - - updateFooterText(footerText) - } - - fun updateFooterText(text: String?) { - val currentItems = adapter - .viewItems - .filter { it !is MessageListViewItem.Footer } - .toMutableList() - - if (!text.isNullOrEmpty()) { - currentItems.add(MessageListViewItem.Footer(text)) - } - - adapter.viewItems = currentItems - } - - private fun selectAll() { - if (adapter.viewItems.isEmpty()) { - // Nothing to do if there are no messages - return - } - - adapter.selectAll() - - if (actionMode == null) { - startAndPrepareActionMode() - } - - computeBatchDirection() - updateActionMode() - } - - private fun toggleMessageSelect(messageListItem: MessageListItem) { - adapter.toggleSelection(messageListItem) - updateAfterSelectionChange() - } - - private fun selectMessage(messageListItem: MessageListItem) { - adapter.selectMessage(messageListItem) - updateAfterSelectionChange() - } - - private fun deselectMessage(messageListItem: MessageListItem) { - adapter.deselectMessage(messageListItem) - updateAfterSelectionChange() - } - - private fun isMessageSelected(messageListItem: MessageListItem): Boolean { - return adapter.isSelected(messageListItem) - } - - private fun updateAfterSelectionChange() { - if (adapter.selectedCount == 0) { - actionMode?.finish() - actionMode = null - return - } - - if (actionMode == null) { - startAndPrepareActionMode() - } - - computeBatchDirection() - updateActionMode() - } - - override fun onToggleMessageSelection(item: MessageListItem) { - toggleMessageSelect(item) - } - - override fun onToggleMessageFlag(item: MessageListItem) { - setFlag(item, Flag.FLAGGED, !item.isStarred) - } - - private fun updateActionMode() { - val actionMode = actionMode ?: error("actionMode == null") - actionMode.title = getString(R.string.actionbar_selected, adapter.selectedCount) - actionModeCallback.showSelectAll(!adapter.isAllSelected) - - actionMode.invalidate() - } - - private fun computeBatchDirection() { - val selectedMessages = adapter.selectedMessages - val notAllRead = !selectedMessages.all { it.isRead } - val notAllStarred = !selectedMessages.all { it.isStarred } - - actionModeCallback.showMarkAsRead(notAllRead) - actionModeCallback.showFlag(notAllStarred) - } - - private fun setFlag(messageListItem: MessageListItem, flag: Flag, newState: Boolean) { - val account = messageListItem.account - if (showingThreadedList && messageListItem.threadCount > 1) { - val threadRootId = messageListItem.threadRoot - messagingController.setFlagForThreads(account.id, listOf(threadRootId), flag, newState) - } else { - val messageId = messageListItem.databaseId - messagingController.setFlag(account.id, listOf(messageId), flag, newState) - } - - computeBatchDirection() - } - - private fun setFlagForSelected(flag: Flag, newState: Boolean) { - if (adapter.selected.isEmpty()) return - - val messageMap = mutableMapOf>() - val threadMap = mutableMapOf>() - val accounts = mutableSetOf() - - for (messageListItem in adapter.selectedMessages) { - val account = messageListItem.account - accounts.add(account) - - if (showingThreadedList && messageListItem.threadCount > 1) { - val threadRootIdList = threadMap.getOrPut(account) { mutableListOf() } - threadRootIdList.add(messageListItem.threadRoot) - } else { - val messageIdList = messageMap.getOrPut(account) { mutableListOf() } - messageIdList.add(messageListItem.databaseId) - } - } - - for (account in accounts) { - messageMap[account]?.let { messageIds -> - messagingController.setFlag(account.id, messageIds, flag, newState) - } - - threadMap[account]?.let { threadRootIds -> - messagingController.setFlagForThreads(account.id, threadRootIds, flag, newState) - } - } - - computeBatchDirection() - } - - private fun onMove(message: MessageReference) { - onMove(listOf(message)) - } - - private fun onMove(messages: List) { - if (!checkCopyOrMovePossible(messages, FolderOperation.MOVE)) return - - val folderId = when { - isThreadDisplay -> messages.first().folderId - isSingleFolderMode -> currentFolder!!.databaseId - else -> null - } - - displayFolderChoice( - operation = FolderOperation.MOVE, - sourceFolderId = folderId, - accountUuid = messages.first().accountUuid, - lastSelectedFolderId = null, - messages = messages, - ) - } - - private fun onCopy(message: MessageReference) { - onCopy(listOf(message)) - } - - private fun onCopy(messages: List) { - if (!checkCopyOrMovePossible(messages, FolderOperation.COPY)) return - - val folderId = when { - isThreadDisplay -> messages.first().folderId - isSingleFolderMode -> currentFolder!!.databaseId - else -> null - } - - displayFolderChoice( - operation = FolderOperation.COPY, - sourceFolderId = folderId, - accountUuid = messages.first().accountUuid, - lastSelectedFolderId = null, - messages = messages, - ) - } - - private fun displayFolderChoice( - operation: FolderOperation, - sourceFolderId: Long?, - accountUuid: String, - lastSelectedFolderId: Long?, - messages: List, - ) { - // Remember the selected messages so they are available in the registerForActivityResult() callbacks - activeMessages = messages - - val input = ChooseFolderResultContract.Input( - accountUuid = accountUuid, - currentFolderId = sourceFolderId, - scrollToFolderId = lastSelectedFolderId, - ) - when (operation) { - FolderOperation.COPY -> chooseFolderForCopyLauncher.launch(input) - FolderOperation.MOVE -> chooseFolderForMoveLauncher.launch(input) - } - } - - private fun handleChooseFolderResult( - result: ChooseFolderResultContract.Result?, - action: (Long, List) -> Unit, - ) { - if (result == null) return - - val destinationFolderId = result.folderId - val messages = activeMessages!! - - if (destinationFolderId != -1L) { - activeMessages = null - - if (messages.isNotEmpty()) { - setLastSelectedFolder(messages, destinationFolderId) - } - - action(destinationFolderId, messages) - } - } - - private fun setLastSelectedFolder(messages: List, folderId: Long) { - val firstMessage = messages.firstOrNull() ?: return - val account = accountManager.getAccount(firstMessage.accountUuid) ?: return - accountManager.saveAccount( - account.copy( - lastSelectedFolderId = folderId, - ), - ) - } - - private fun onArchive(message: MessageReference) { - onArchive(listOf(message)) - } - - private fun onArchive(messages: List) { - if (!checkCopyOrMovePossible(messages, FolderOperation.MOVE)) return - - if (showingThreadedList) { - messagingController.archiveThreads(messages) - } else { - messagingController.archiveMessages(messages) - } - } - - private fun groupMessagesByAccount( - messages: List, - ): Map> { - return messages.groupBy { accountManager.getAccount(it.accountUuid)!! } - } - - private fun onSpam(messages: List) { - if (interactionSettings.isConfirmSpam) { - // remember the message selection for #onCreateDialog(int) - activeMessages = messages - showDialog(R.id.dialog_confirm_spam) - } else { - onSpamConfirmed(messages) - } - } - - private fun onSpamConfirmed(messages: List) { - for ((account, messagesInAccount) in groupMessagesByAccount(messages)) { - account.spamFolderId?.let { spamFolderId -> - move(messagesInAccount, spamFolderId) - } - } - } - - private fun checkCopyOrMovePossible(messages: List, operation: FolderOperation): Boolean { - if (messages.isEmpty()) return false - - val account = accountManager.getAccount(messages.first().accountUuid) ?: return false - if (operation == FolderOperation.MOVE && - !messagingController.isMoveCapable(account.id) || - operation == FolderOperation.COPY && - !messagingController.isCopyCapable(account.id) - ) { - return false - } - - for (message in messages) { - if (operation == FolderOperation.MOVE && - !messagingController.isMoveCapable(message) || - operation == FolderOperation.COPY && - !messagingController.isCopyCapable(message) - ) { - val toast = Toast.makeText( - activity, - R.string.move_copy_cannot_copy_unsynced_message, - Toast.LENGTH_LONG, - ) - toast.show() - return false - } - } - - return true - } - - private fun copy(messages: List, folderId: Long) { - copyOrMove(messages, folderId, FolderOperation.COPY) - } - - private fun move(messages: List, folderId: Long) { - copyOrMove(messages, folderId, FolderOperation.MOVE) - } - - private fun copyOrMove(messages: List, destinationFolderId: Long, operation: FolderOperation) { - if (!checkCopyOrMovePossible(messages, operation)) return - - val folderMap = messages.asSequence() - .filterNot { it.folderId == destinationFolderId } - .groupBy { it.folderId } - - for ((folderId, messagesInFolder) in folderMap) { - val account = accountManager.getAccount(messagesInFolder.first().accountUuid) - if (account == null) { - logger.debug(TAG) { - "Account for message ${messagesInFolder.first()} not found, skipping copy/move operation" - } - continue - } - - when (operation) { - FolderOperation.MOVE if showingThreadedList -> { - messagingController.moveMessagesInThread( - account.id, - folderId, - messagesInFolder, - destinationFolderId, - ) - } - - FolderOperation.MOVE -> { - messagingController.moveMessages( - account.id, - folderId, - messagesInFolder, - destinationFolderId, - ) - } - - FolderOperation.COPY if showingThreadedList -> { - messagingController.copyMessagesInThread( - account.id, - folderId, - messagesInFolder, - destinationFolderId, - ) - } - - FolderOperation.COPY -> { - messagingController.copyMessages( - account.id, - folderId, - messagesInFolder, - destinationFolderId, - ) - } - } - } - } - - private fun onMoveToDraftsFolder(messages: List) { - account?.id?.let { messagingController.moveToDraftsFolder(it, currentFolder!!.databaseId, messages) } - activeMessages = null - } - - override fun doPositiveClick(dialogId: Int) { - when (dialogId) { - R.id.dialog_confirm_spam -> { - onSpamConfirmed(activeMessages!!) - activeMessages = null - } - - R.id.dialog_confirm_delete -> { - onDeleteConfirmed(activeMessages!!) - activeMessage = null - adapter.activeMessage = null - } - - R.id.dialog_confirm_mark_all_as_read -> { - markAllAsRead() - } - - R.id.dialog_confirm_empty_spam -> { - account?.id?.let { messagingController.emptySpam(it) } - } - - R.id.dialog_confirm_empty_trash -> { - account?.id?.let { messagingController.emptyTrash(it) } - } - } - } - - override fun doNegativeClick(dialogId: Int) { - if (dialogId == R.id.dialog_confirm_spam || dialogId == R.id.dialog_confirm_delete) { - val activeMessages = this.activeMessages ?: return - if (activeMessages.size == 1) { - // List item might have been swiped and is still showing the "swipe action background" - resetSwipedView(activeMessages.first()) - } - - this.activeMessages = null - } - } - - private fun resetSwipedView(messageReference: MessageReference) { - val recyclerView = this.recyclerView ?: return - val itemTouchHelper = this.itemTouchHelper ?: return - - adapter.getItem(messageReference)?.let { messageListItem -> - recyclerView.findViewHolderForItemId(messageListItem.uniqueId)?.let { viewHolder -> - itemTouchHelper.stopSwipe(viewHolder) - notifyItemChanged(messageListItem) - } - } - } - - override fun dialogCancelled(dialogId: Int) { - doNegativeClick(dialogId) - } - - private fun checkMail() { - if (isSingleAccountMode && isSingleFolderMode) { - val folderId = currentFolder!!.databaseId - account?.id?.let { messagingController.synchronizeMailbox(it, folderId, false, activityListener) } - account?.id?.let { messagingController.sendPendingMessages(it, activityListener) } - } else if (allAccounts) { - messagingController.checkMail(null, true, true, false, activityListener) - } else { - for (accountUuid in accountUuids) { - val account = accountManager.getAccount(accountUuid) - account?.id?.let { messagingController.checkMail(it, true, true, false, activityListener) } - } - } - } - - override fun onStop() { - // If we represent a remote search, then kill that before going back. - if (isRemoteSearch && remoteSearchFuture != null) { - try { - Log.i("Remote search in progress, attempting to abort...") - - // Canceling the future stops any message fetches in progress. - val cancelSuccess = remoteSearchFuture!!.cancel(true) // mayInterruptIfRunning = true - if (!cancelSuccess) { - Log.e("Could not cancel remote search future.") - } - - // Closing the folder will kill off the connection if we're mid-search. - val searchAccount = account!! - - // Send a remoteSearchFinished() message for good measure. - activityListener.remoteSearchFinished( - currentFolder!!.databaseId, - 0, - searchAccount.remoteSearchNumResults, - null, - ) - } catch (e: Exception) { - // Since the user is going back, log and squash any exceptions. - Log.e(e, "Could not abort remote search before going back") - } - } - - super.onStop() - } - - fun openMessage(messageReference: MessageReference) { - fragmentListener.openMessage(messageReference) - } - - fun onReverseSort() { - changeSort(sortType) - } - - private val selectedMessage: MessageReference? - get() = selectedMessageListItem?.messageReference - - private val selectedMessageListItem: MessageListItem? - get() { - val recyclerView = recyclerView ?: return null - val focusedView = recyclerView.focusedChild ?: return null - val viewHolder = recyclerView.findContainingViewHolder(focusedView) as? MessageViewHolder ?: return null - return adapter.getItemById(viewHolder.uniqueId) - } - - private val selectedMessages: List - get() = adapter.selectedMessages.map { it.messageReference } - - fun onDelete() { - selectedMessage?.let { message -> - onDelete(listOf(message)) - } - } - - fun toggleMessageSelect() { - selectedMessageListItem?.let { messageListItem -> - toggleMessageSelect(messageListItem) - } - } - - fun onToggleFlagged() { - selectedMessageListItem?.let { messageListItem -> - setFlag(messageListItem, Flag.FLAGGED, !messageListItem.isStarred) - } - } - - fun onToggleRead() { - selectedMessageListItem?.let { messageListItem -> - setFlag(messageListItem, Flag.SEEN, !messageListItem.isRead) - } - } - - fun onMove() { - selectedMessage?.let { message -> - onMove(message) - } - } - - fun onArchive() { - selectedMessage?.let { message -> - onArchive(message) - } - } - - fun onCopy() { - selectedMessage?.let { message -> - onCopy(message) - } - } - - val isOutbox: Boolean - get() = isSpecialFolder(account?.id?.let(outboxFolderManager::getOutboxFolderIdSync)) - - private val isInbox: Boolean - get() = isSpecialFolder(account?.inboxFolderId) - - private val isArchiveFolder: Boolean - get() = isSpecialFolder(account?.archiveFolderId) - - private val isSpamFolder: Boolean - get() = isSpecialFolder(account?.spamFolderId) - - private fun isSpecialFolder(specialFolderId: Long?): Boolean { - val folderId = specialFolderId ?: return false - val currentFolder = currentFolder ?: return false - return currentFolder.databaseId == folderId - } - - private val isRemoteFolder: Boolean - get() { - if (localSearch.isManualSearch || isOutbox) return false - - val accountId = account?.id - return if (accountId == null || !messagingController.isMoveCapable(accountId)) { - // For POP3 accounts only the Inbox is a remote folder. - isInbox - } else { - true - } - } - - private val isManualSearch: Boolean - get() = localSearch.isManualSearch - - private fun shouldShowExpungeAction(): Boolean { - val account = this.account ?: return false - return account.expungePolicy == Expunge.EXPUNGE_MANUALLY && messagingController.supportsExpunge(account.id) - } - - private fun onRemoteSearch() { - // Remote search is useless without the network. - if (hasConnectivity == true) { - onRemoteSearchRequested() - } else { - Toast.makeText(activity, getText(R.string.remote_search_unavailable_no_network), Toast.LENGTH_SHORT).show() - } - } - - private val isRemoteSearchAllowed: Boolean - get() = isManualSearch && - !isRemoteSearch && - isSingleFolderMode && - (account?.id?.let { messagingController.isPushCapable(it) } == true) - - fun onSearchRequested(query: String): Boolean { - val folderId = currentFolder?.databaseId - return fragmentListener.startSearch(query, account, folderId) - } - - private fun setMessageList(messageListInfo: MessageListInfo) { - val messageListItems = messageListInfo.messageListItems - if (isThreadDisplay && messageListItems.isEmpty()) { - goBack() - return - } - - swipeRefreshLayout?.let { swipeRefreshLayout -> - swipeRefreshLayout.isRefreshing = false - swipeRefreshLayout.isEnabled = isPullToRefreshAllowed - } - - if (isThreadDisplay) { - if (messageListItems.isNotEmpty()) { - val strippedSubject = messageListItems.first().subject?.let { Utility.stripSubject(it) } - threadTitle = if (strippedSubject.isNullOrEmpty()) { - getString(R.string.general_no_subject) - } else { - strippedSubject - } - updateTitle() - } else { - // TODO: empty thread view -> return to full message list - } - } - - adapter.viewItems = buildList { - if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications).isEnabled()) { - add(MessageListViewItem.InAppNotificationBannerList) - } - addAll(messageListItems.map { MessageListViewItem.Message(it) }) - } - - rememberedSelected?.let { - rememberedSelected = null - adapter.restoreSelected(it) - } - - messageListItems - .map { it.account } - .toSet() - .forEach { account -> messagingController.checkAuthenticationProblem(account.id) } - - resetActionMode() - computeBatchDirection() - - invalidateMenu() - - initialMessageListLoad = false - - currentFolder?.let { currentFolder -> - currentFolder.moreMessages = messageListInfo.hasMoreMessages - updateFooterText() - } - } - - private fun resetActionMode() { - if (!isResumed) return - - if (!isActive || adapter.selected.isEmpty()) { - actionMode?.finish() - actionMode = null - return - } - - if (actionMode == null) { - startAndPrepareActionMode() - } - - updateActionMode() - } - - private fun startAndPrepareActionMode() { - actionMode = fragmentListener.startSupportActionMode(actionModeCallback) - actionMode?.invalidate() - } - - fun finishActionMode() { - actionMode?.finish() - } - - fun remoteSearchFinished() { - remoteSearchFuture = null - } - - fun setActiveMessage(messageReference: MessageReference?) { - activeMessage = messageReference - - rememberSortOverride(messageReference) - - // Reload message list with modified query that always includes the active message - if (isAdded) { - loadMessageList() - } - - // Redraw list immediately - if (::adapter.isInitialized) { - adapter.activeMessage = activeMessage - - if (messageReference != null) { - scrollToMessage(messageReference) - } - } - } - - fun onFullyActive() { - maybeShowFloatingActionButton() - } - - private fun maybeShowFloatingActionButton() { - floatingActionButton?.isVisible = true - } - - private fun maybeHideFloatingActionButton() { - floatingActionButton?.isGone = true - } - - // For the last N displayed messages we remember the original 'read' and 'starred' state of the messages. We pass - // this information to MessageListLoader so messages can be sorted according to these remembered values and not the - // current state. This way messages, that are marked as read/unread or starred/not starred while being displayed, - // won't immediately change position in the message list if the list is sorted by these fields. - // The main benefit is that the swipe to next/previous message feature will work in a less surprising way. - private fun rememberSortOverride(messageReference: MessageReference?) { - val messageSortOverrides = viewModel.messageSortOverrides - - if (messageReference == null) { - messageSortOverrides.clear() - return - } - - if (sortType != SortType.SORT_UNREAD && sortType != SortType.SORT_FLAGGED) return - - val messageListItem = adapter.getItem(messageReference) ?: return - - val existingEntry = messageSortOverrides.firstOrNull { it.first == messageReference } - if (existingEntry != null) { - messageSortOverrides.remove(existingEntry) - messageSortOverrides.addLast(existingEntry) - } else { - messageSortOverrides.addLast( - messageReference to MessageSortOverride(messageListItem.isRead, messageListItem.isStarred), - ) - if (messageSortOverrides.size > MAXIMUM_MESSAGE_SORT_OVERRIDES) { - messageSortOverrides.removeFirst() - } - } - } - - private fun scrollToMessage(messageReference: MessageReference) { - val recyclerView = recyclerView ?: return - val messageListItem = adapter.getItem(messageReference) ?: return - val position = adapter.getPosition(messageListItem) ?: return - - val linearLayoutManager = recyclerView.layoutManager as LinearLayoutManager - val firstVisiblePosition = linearLayoutManager.findFirstCompletelyVisibleItemPosition() - val lastVisiblePosition = linearLayoutManager.findLastCompletelyVisibleItemPosition() - if (position !in firstVisiblePosition..lastVisiblePosition) { - recyclerView.smoothScrollToPosition(position) - } - } - - private val isMarkAllAsReadSupported: Boolean - get() = isSingleAccountMode && isSingleFolderMode && !isOutbox - - private fun confirmMarkAllAsRead() { - if (interactionSettings.isConfirmMarkAllRead) { - showDialog(R.id.dialog_confirm_mark_all_as_read) - } else { - markAllAsRead() - } - } - - private fun markAllAsRead() { - if (isMarkAllAsReadSupported) { - account?.id?.let { messagingController.markAllMessagesRead(it, currentFolder!!.databaseId) } - } - } - - private fun invalidateMenu() { - activity?.invalidateMenu() - } - - private val isCheckMailSupported: Boolean - get() = allAccounts || !isSingleAccountMode || !isSingleFolderMode || isRemoteFolder - - private val isCheckMailAllowed: Boolean - get() = !isManualSearch && isCheckMailSupported - - private val isPullToRefreshAllowed: Boolean - get() = isRemoteSearchAllowed || isCheckMailAllowed - - private var itemSelectedOnSwipeStart = false - - private val swipeListener = object : MessageListSwipeListener { - override fun onSwipeStarted(item: MessageListItem, action: SwipeAction) { - swipeRefreshLayout?.isEnabled = false - itemSelectedOnSwipeStart = isMessageSelected(item) - if (itemSelectedOnSwipeStart && action != SwipeAction.ToggleSelection) { - deselectMessage(item) - } - } - - override fun onSwipeActionChanged(item: MessageListItem, action: SwipeAction) { - if (action == SwipeAction.ToggleSelection) { - if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { - selectMessage(item) - } - } else if (isMessageSelected(item)) { - deselectMessage(item) - } - } - - override fun onSwipeAction(item: MessageListItem, action: SwipeAction) { - if (action.removesItem || action == SwipeAction.ToggleSelection) { - itemSelectedOnSwipeStart = false - } - - when (action) { - SwipeAction.None -> Unit - SwipeAction.ToggleSelection -> { - toggleMessageSelect(item) - } - - SwipeAction.ToggleRead -> { - setFlag(item, Flag.SEEN, !item.isRead) - } - - SwipeAction.ToggleStar -> { - setFlag(item, Flag.FLAGGED, !item.isStarred) - } - - SwipeAction.ArchiveDisabled -> - Snackbar - .make( - requireNotNull(view), - R.string.archiving_not_available_for_this_account, - Snackbar.LENGTH_LONG, - ) - .show() - - SwipeAction.ArchiveSetupArchiveFolder -> setupArchiveFolderDialogFragmentFactory.show( - accountUuid = item.account.uuid, - fragmentManager = parentFragmentManager, - ) - - SwipeAction.Archive -> { - onArchive(item.messageReference) - } - - SwipeAction.Delete -> { - onDelete(listOf(item.messageReference)) - } - - SwipeAction.Spam -> { - onSpam(listOf(item.messageReference)) - } - - SwipeAction.Move -> { - val messageReference = item.messageReference - resetSwipedView(messageReference) - onMove(messageReference) - } - } - } - - override fun onSwipeEnded(item: MessageListItem) { - swipeRefreshLayout?.isEnabled = true - if (itemSelectedOnSwipeStart && !isMessageSelected(item)) { - selectMessage(item) - } - } - } - - private fun notifyItemChanged(item: MessageListItem) { - val position = adapter.getPosition(item) ?: return - adapter.notifyItemChanged(position) - } - - private val swipeActionSupportProvider = SwipeActionSupportProvider { item, action -> - when (action) { - SwipeAction.None -> false - SwipeAction.ToggleSelection -> true - SwipeAction.ToggleRead -> !isOutbox - SwipeAction.ToggleStar -> !isOutbox - SwipeAction.Archive, SwipeAction.ArchiveDisabled, SwipeAction.ArchiveSetupArchiveFolder -> { - !isOutbox && item.folderId != item.account.archiveFolderId - } - - SwipeAction.Delete -> true - SwipeAction.Move -> !isOutbox && messagingController.isMoveCapable(item.account.id) - SwipeAction.Spam -> !isOutbox && item.account.hasSpamFolder() && item.folderId != item.account.spamFolderId - } - } - - override fun filterInAppNotificationEvents(notification: InAppNotification): Boolean { - val accountUuid = notification.accountUuid - return notification !is SentFolderNotFoundNotification && - accountUuid != null && - accountUuid in accountUuids - } - - override fun onNotificationActionClicked(action: NotificationAction) = onNotificationActionClick(action) - - override fun onNotificationActionClick(action: NotificationAction) { - when (action) { - is NotificationAction.UpdateIncomingServerSettings -> - FeatureLauncherActivity.launch( - context = requireContext(), - target = FeatureLauncherTarget.AccountEditIncomingSettings(action.accountUuid), - ) - - is NotificationAction.UpdateOutgoingServerSettings -> - FeatureLauncherActivity.launch( - context = requireContext(), - target = FeatureLauncherTarget.AccountEditOutgoingSettings(action.accountUuid), - ) - - is NotificationAction.OpenNotificationCentre -> - errorNotificationsDialogFragmentFactory.show(fragmentManager = childFragmentManager) - - else -> Unit - } - } - - internal inner class MessageListActivityListener : SimpleMessagingListener() { - private val lock = Any() - - @GuardedBy("lock") - private var folderCompleted = 0 - - @GuardedBy("lock") - private var folderTotal = 0 - - override fun remoteSearchFailed(folderServerId: String?, err: String?) { - handler.post { - activity?.let { activity -> - Toast.makeText(activity, R.string.remote_search_error, Toast.LENGTH_LONG).show() - } - } - } - - override fun remoteSearchStarted(folderId: Long) { - handler.progress(true) - handler.updateFooter(getString(R.string.remote_search_sending_query)) - } - - override fun enableProgressIndicator(enable: Boolean) { - handler.progress(enable) - } - - override fun remoteSearchFinished( - folderId: Long, - numResults: Int, - maxResults: Int, - extraResults: List?, - ) { - handler.progress(false) - handler.remoteSearchFinished() - - extraSearchResults = extraResults - if (extraResults != null && extraResults.isNotEmpty()) { - handler.updateFooter(String.format(getString(R.string.load_more_messages_fmt), maxResults)) - } else { - handler.updateFooter(null) - } - } - - override fun remoteSearchServerQueryComplete(folderId: Long, numResults: Int, maxResults: Int) { - handler.progress(true) - - val footerText = if (maxResults != 0 && numResults > maxResults) { - resources.getQuantityString( - R.plurals.remote_search_downloading_limited, - maxResults, - maxResults, - numResults, - ) - } else { - resources.getQuantityString(R.plurals.remote_search_downloading, numResults, numResults) - } - - handler.updateFooter(footerText) - informUserOfStatus() - } - - private fun informUserOfStatus() { - handler.refreshTitle() - } - - override fun synchronizeMailboxStarted(account: LegacyAccountDto, folderId: Long) { - if (updateForMe(account, folderId)) { - handler.progress(true) - handler.folderLoading(folderId, true) - - synchronized(lock) { - folderCompleted = 0 - folderTotal = 0 - } - - informUserOfStatus() - } - } - - override fun synchronizeMailboxHeadersProgress( - account: LegacyAccountDto, - folderServerId: String, - completed: Int, - total: Int, - ) { - synchronized(lock) { - folderCompleted = completed - folderTotal = total - } - - informUserOfStatus() - } - - override fun synchronizeMailboxHeadersFinished( - account: LegacyAccountDto, - folderServerId: String, - total: Int, - completed: Int, - ) { - synchronized(lock) { - folderCompleted = 0 - folderTotal = 0 - } - - informUserOfStatus() - } - - override fun synchronizeMailboxProgress(account: LegacyAccountDto, folderId: Long, completed: Int, total: Int) { - synchronized(lock) { - folderCompleted = completed - folderTotal = total - } - - informUserOfStatus() - } - - override fun synchronizeMailboxFinished(account: LegacyAccountDto, folderId: Long) { - if (updateForMe(account, folderId)) { - handler.progress(false) - handler.folderLoading(folderId, false) - } - } - - override fun synchronizeMailboxFailed(account: LegacyAccountDto, folderId: Long, message: String) { - if (updateForMe(account, folderId)) { - handler.progress(false) - handler.folderLoading(folderId, false) - } - } - - override fun checkMailFinished(context: Context?, account: LegacyAccountDto?) { - handler.progress(false) - } - - private fun updateForMe(account: LegacyAccountDto?, folderId: Long): Boolean { - if (account == null || account.uuid !in accountUuids) return false - - val folderIds = localSearch.folderIds - return folderIds.isEmpty() || folderId in folderIds - } - - fun getFolderCompleted(): Int { - synchronized(lock) { - return folderCompleted - } - } - - fun getFolderTotal(): Int { - synchronized(lock) { - return folderTotal - } - } - } - - internal inner class ActionModeCallback : ActionMode.Callback { - private var selectAll: MenuItem? = null - private var markAsRead: MenuItem? = null - private var markAsUnread: MenuItem? = null - private var flag: MenuItem? = null - private var unflag: MenuItem? = null - private var disableMarkAsRead = false - private var disableFlag = false - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - selectAll = menu.findItem(R.id.select_all) - markAsRead = menu.findItem(R.id.mark_as_read) - markAsUnread = menu.findItem(R.id.mark_as_unread) - flag = menu.findItem(R.id.flag) - unflag = menu.findItem(R.id.unflag) - - // we don't support cross account actions atm - if (!isSingleAccountMode) { - val accounts = accountUuidsForSelected.mapNotNull { accountUuid -> - accountManager.getAccount(accountUuid) - } - - menu.findItem(R.id.move).isVisible = true - menu.findItem(R.id.copy).isVisible = true - - // Disable archive/spam options here and maybe enable below when checking account capabilities - menu.findItem(R.id.archive).isVisible = false - menu.findItem(R.id.spam).isVisible = false - - for (account in accounts) { - setContextCapabilities(account, menu) - } - } - - return true - } - - private val accountUuidsForSelected: Set - get() = adapter.selectedMessages.mapToSet { it.account.uuid } - - override fun onDestroyActionMode(mode: ActionMode) { - actionMode = null - selectAll = null - markAsRead = null - markAsUnread = null - flag = null - unflag = null - - adapter.clearSelected() - } - - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.message_list_context_menu, menu) - - setContextCapabilities(account, menu) - return true - } - - private fun setContextCapabilities(account: LegacyAccount?, menu: Menu) { - if (!isSingleAccountMode || account == null) { - // We don't support cross-account copy/move operations right now - menu.findItem(R.id.move).isVisible = false - menu.findItem(R.id.copy).isVisible = false - - if (account?.hasArchiveFolder() == true) { - menu.findItem(R.id.archive).isVisible = true - } - - if (account?.hasSpamFolder() == true) { - menu.findItem(R.id.spam).isVisible = true - } - } else if (isOutbox) { - menu.findItem(R.id.mark_as_read).isVisible = false - menu.findItem(R.id.mark_as_unread).isVisible = false - menu.findItem(R.id.archive).isVisible = false - menu.findItem(R.id.copy).isVisible = false - menu.findItem(R.id.flag).isVisible = false - menu.findItem(R.id.unflag).isVisible = false - menu.findItem(R.id.spam).isVisible = false - menu.findItem(R.id.move).isVisible = false - - disableMarkAsRead = true - disableFlag = true - - if (account.hasDraftsFolder()) { - menu.findItem(R.id.move_to_drafts).isVisible = true - } - } else { - if (!messagingController.isCopyCapable(account.id)) { - menu.findItem(R.id.copy).isVisible = false - } - - if (!messagingController.isMoveCapable(account.id)) { - menu.findItem(R.id.move).isVisible = false - menu.findItem(R.id.archive).isVisible = false - menu.findItem(R.id.spam).isVisible = false - } else { - if (!account.hasArchiveFolder() || isArchiveFolder) { - menu.findItem(R.id.archive).isVisible = false - } - - if (!account.hasSpamFolder() || isSpamFolder) { - menu.findItem(R.id.spam).isVisible = false - } - } - } - } - - fun showSelectAll(show: Boolean) { - selectAll?.isVisible = show - } - - fun showMarkAsRead(show: Boolean) { - if (!disableMarkAsRead) { - markAsRead?.isVisible = show - markAsUnread?.isVisible = !show - } - } - - fun showFlag(show: Boolean) { - if (!disableFlag) { - flag?.isVisible = show - unflag?.isVisible = !show - } - } - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - // In the following we assume that we can't move or copy mails to the same folder. Also that spam isn't - // available if we are in the spam folder, same for archive. - - val endSelectionMode = when (item.itemId) { - R.id.delete -> { - onDelete(selectedMessages) - true - } - - R.id.mark_as_read -> { - setFlagForSelected(Flag.SEEN, true) - false - } - - R.id.mark_as_unread -> { - setFlagForSelected(Flag.SEEN, false) - false - } - - R.id.flag -> { - setFlagForSelected(Flag.FLAGGED, true) - false - } - - R.id.unflag -> { - setFlagForSelected(Flag.FLAGGED, false) - false - } - - R.id.select_all -> { - selectAll() - false - } - - R.id.archive -> { - onArchive(selectedMessages) - // TODO: Only finish action mode if all messages have been moved. - true - } - - R.id.spam -> { - onSpam(selectedMessages) - // TODO: Only finish action mode if all messages have been moved. - true - } - - R.id.move -> { - onMove(selectedMessages) - true - } - - R.id.move_to_drafts -> { - onMoveToDraftsFolder(selectedMessages) - true - } - - R.id.copy -> { - onCopy(selectedMessages) - true - } - - else -> return false - } - - if (endSelectionMode) { - mode.finish() - } - - return true - } - } - - private enum class FolderOperation { - COPY, - MOVE, - } - - @Suppress("detekt.UnnecessaryAnnotationUseSiteTarget") // https://github.com/detekt/detekt/issues/8212 - private enum class Error(@param:StringRes val errorText: Int) { - FolderNotFound(R.string.message_list_error_folder_not_found), - } - - interface MessageListFragmentListener { - fun setMessageListProgressEnabled(enable: Boolean) - fun setMessageListProgress(level: Int) - fun showThread(account: LegacyAccount, threadRootId: Long) - fun openMessage(messageReference: MessageReference) - fun setMessageListTitle(title: String, subtitle: String? = null) - fun onCompose(account: LegacyAccount?) - fun startSearch(query: String, account: LegacyAccount?, folderId: Long?): Boolean - fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? - fun goBack() - - companion object { - const val MAX_PROGRESS = 10000 - } - } - - companion object { - - private const val ARG_SEARCH = "searchObject" - private const val ARG_THREADED_LIST = "showingThreadedList" - private const val ARG_IS_THREAD_DISPLAY = "isThreadedDisplay" - - private const val STATE_SELECTED_MESSAGES = "selectedMessages" - private const val STATE_ACTIVE_MESSAGES = "activeMessages" - private const val STATE_ACTIVE_MESSAGE = "activeMessage" - private const val STATE_REMOTE_SEARCH_PERFORMED = "remoteSearchPerformed" + private fun MessageListPreferences.toMessageListAppearance(): MessageListAppearance = MessageListAppearance( + fontSizes = FontSizes(), + previewLines = excerptLines, + stars = showFavouriteButton, + senderAboveSubject = senderAboveSubject, + showContactPicture = showMessageAvatar, + showingThreadedList = groupConversations, + backGroundAsReadIndicator = colorizeBackgroundWhenRead, + showAccountIndicator = isShowAccountIndicator, + density = density, + ) - fun newInstance( + companion object Factory : AbstractMessageListFragment.Factory { + override fun newInstance( search: LocalMessageSearch, isThreadDisplay: Boolean, threadedList: Boolean, diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java index c3cd533ae84..724761c25ee 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListHandler.java @@ -21,9 +21,9 @@ public class MessageListHandler extends Handler { private static final int ACTION_PROGRESS = 3; private static final int ACTION_REMOTE_SEARCH_FINISHED = 4; - private WeakReference mFragment; + private WeakReference mFragment; - public MessageListHandler(MessageListFragment fragment) { + public MessageListHandler(AbstractMessageListFragment fragment) { mFragment = new WeakReference<>(fragment); } public void folderLoading(long folderId, boolean loading) { @@ -52,7 +52,7 @@ public void updateFooter(final String message) { post(new Runnable() { @Override public void run() { - MessageListFragment fragment = mFragment.get(); + AbstractMessageListFragment fragment = mFragment.get(); if (fragment != null) { fragment.updateFooterText(message); } @@ -62,7 +62,7 @@ public void run() { @Override public void handleMessage(android.os.Message msg) { - MessageListFragment fragment = mFragment.get(); + AbstractMessageListFragment fragment = mFragment.get(); if (fragment == null) { return; } diff --git a/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml b/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml index 8b4d730bbbb..b049715520a 100644 --- a/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml +++ b/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml @@ -6,7 +6,7 @@ android:id="@+id/message_list_coordinator" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".messagelist.MessageListFragment" + tools:context=".messagelist.LegacyMessageListFragment" >