diff --git a/core/common/src/commonJvmMain/kotlin/net/thunderbird/core/common/state/debug/StatePrettyPrinter.commonJvm.kt b/core/common/src/commonJvmMain/kotlin/net/thunderbird/core/common/state/debug/StatePrettyPrinter.commonJvm.kt index f5b0fd04ed2..03bad52f8c2 100644 --- a/core/common/src/commonJvmMain/kotlin/net/thunderbird/core/common/state/debug/StatePrettyPrinter.commonJvm.kt +++ b/core/common/src/commonJvmMain/kotlin/net/thunderbird/core/common/state/debug/StatePrettyPrinter.commonJvm.kt @@ -1,7 +1,5 @@ package net.thunderbird.core.common.state.debug -import java.lang.reflect.InaccessibleObjectException - internal actual fun T.toPropertyMap(): Map { if (this is Collection<*> || this is Map<*, *>) return emptyMap() @@ -15,7 +13,7 @@ internal actual fun T.toPropertyMap(): Map { try { field.isAccessible = true put(field.name, field[this@toPropertyMap]) - } catch (_: InaccessibleObjectException) { + } catch (_: RuntimeException) { // Skip fields that cannot be accessed (e.g., platform types in named modules) } } 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 index bec215462ff..820b5030340 100644 --- 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 @@ -9,7 +9,6 @@ import net.thunderbird.core.ui.compose.common.mvi.BaseStateMachineViewModel import net.thunderbird.core.ui.contract.mvi.BaseViewModel import net.thunderbird.core.ui.contract.mvi.observe import net.thunderbird.feature.account.AccountId -import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences 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 @@ -66,7 +65,6 @@ interface MessageListContract { * @param state The current state of the message list to be rendered. * @param dispatchEvent A lambda function to be invoked when a user action or other UI event occurs. * @param onEffect A lambda function to handle one-time side effects from the ViewModel. - * @param preferences User preferences for the message list, influencing its appearance and behavior. * @param modifier The modifier to be applied to the root container of the message list screen. * @param inAppNotificationEventFilter A filter to decide whether an in-app notification should be displayed. */ @@ -75,7 +73,6 @@ interface MessageListContract { state: MessageListState, dispatchEvent: (MessageListEvent) -> Unit, onEffect: (MessageListEffect) -> Unit, - preferences: MessageListPreferences, modifier: Modifier = Modifier, inAppNotificationEventFilter: (InAppNotification) -> Boolean = { true }, ) @@ -86,7 +83,6 @@ interface MessageListContract { * This is a convenience overload of [Render] that automatically retrieves the [ViewModel] * using Koin and observes its state. * - * @param preferences The user's preferences for the message list. * @param onEffect A callback to handle one-time side effects from the [ViewModel], such as navigation. * @param modifier The modifier to be applied to the layout. * @param viewModel The [ViewModel] instance for this screen. Defaults to the instance provided by Koin. @@ -94,14 +90,13 @@ interface MessageListContract { */ @Composable fun Render( - preferences: MessageListPreferences, onEffect: (MessageListEffect) -> Unit, modifier: Modifier = Modifier, viewModel: ViewModel = koinViewModel(), inAppNotificationEventFilter: (InAppNotification) -> Boolean = { true }, ) { val (state, dispatchEvent) = viewModel.observe(onEffect) - Render(state.value, dispatchEvent, onEffect, preferences, modifier, inAppNotificationEventFilter) + Render(state.value, dispatchEvent, onEffect, modifier, inAppNotificationEventFilter) } } } diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListMetadata.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListMetadata.kt index 411929a9239..26e825e69e3 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListMetadata.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListMetadata.kt @@ -38,7 +38,18 @@ data class MessageListMetadata( val footer: MessageListFooter = MessageListFooter(), val showAccountIndicator: Boolean = false, val paging: PaginationUi = PaginationUi(), -) +) { + /** + * Indicates whether the message list metadata contains all required data to display the message list. + * + * @return `true` when swipe actions are configured, sorting criteria are available, and a folder + * is selected; otherwise, `false` if any of these required components are missing. + */ + val isReady: Boolean + get() = swipeActions.isNotEmpty() && + sortCriteriaPerAccount.isNotEmpty() && + folder != null +} /** * Represents the footer text to display at the bottom of the message list. diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListState.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListState.kt index 2cb446db177..67bfa01ba33 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListState.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageListState.kt @@ -92,10 +92,14 @@ sealed interface MessageListState { override val preferences: MessageListPreferences? = null, override val messages: ImmutableList = persistentListOf(), ) : MessageListState { + /** + * Indicates whether the warming-up state has completed and is ready to transition to an active state. + * + * @return `true` when both the metadata is ready and user preferences have been loaded, signaling + * that the message list screen has completed its initialization phase and can proceed to display content. + */ val isReady: Boolean - get() = metadata.swipeActions.isNotEmpty() && - preferences != null && - metadata.sortCriteriaPerAccount.isNotEmpty() + get() = metadata.isReady && preferences != null } /** diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenLoadingMessagesStatePreviewParams.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenLoadingMessagesStatePreviewParams.kt index 850e113ec35..97d461f9aa9 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenLoadingMessagesStatePreviewParams.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenLoadingMessagesStatePreviewParams.kt @@ -93,7 +93,6 @@ private fun MessageListScreenLoadingMessagesStatePreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSearchingMessagesStatePreviewParams.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSearchingMessagesStatePreviewParams.kt index 98b80157d5e..d0faeb02d0d 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSearchingMessagesStatePreviewParams.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSearchingMessagesStatePreviewParams.kt @@ -87,7 +87,6 @@ private fun MessageListScreenSearchingMessagesStatePreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSelectingMessagesStatePreviewParams.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSelectingMessagesStatePreviewParams.kt index e83bac6308e..70392dc16b9 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSelectingMessagesStatePreviewParams.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenSelectingMessagesStatePreviewParams.kt @@ -62,7 +62,6 @@ private fun MessageListScreenSelectingMessagesStatePreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenWarmingUpStatePreviewParams.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenWarmingUpStatePreviewParams.kt index 2ccc9aec12b..9ccdde13f19 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenWarmingUpStatePreviewParams.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/MessageListScreenWarmingUpStatePreviewParams.kt @@ -54,7 +54,6 @@ private fun MessageListScreenWarmingUpStatePreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesContentPreview.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesContentPreview.kt index bf359314f67..7949eca5ecd 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesContentPreview.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesContentPreview.kt @@ -102,7 +102,6 @@ private fun MessageListScreenLoadedMessagesContentPreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesDensityPreview.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesDensityPreview.kt index 464bcef5596..b71dd31134b 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesDensityPreview.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesDensityPreview.kt @@ -80,7 +80,6 @@ private fun MessageListScreenLoadedMessagesDensityPreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesFooterPreview.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesFooterPreview.kt index 7e2430187ab..4177c580df3 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesFooterPreview.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesFooterPreview.kt @@ -76,7 +76,6 @@ private fun MessageListScreenLoadedMessagesFooterPreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesLayoutPreview.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesLayoutPreview.kt index 621729d2549..b1899741b22 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesLayoutPreview.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/renderer/statepreview/loadedmessages/MessageListScreenLoadedMessagesLayoutPreview.kt @@ -80,7 +80,6 @@ private fun MessageListScreenLayoutPreview( state = params.state, dispatchEvent = {}, onEffect = {}, - preferences = params.preferences, ) } } 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 e3a7bc58f9f..566d8fb9c16 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,6 +1,5 @@ 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.LocalDeleteOperationDecider import net.thunderbird.feature.mail.message.list.domain.DomainContract @@ -10,23 +9,20 @@ import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetAcco import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetMessageListPreferences import net.thunderbird.feature.mail.message.list.internal.domain.usecase.GetSortCriteriaPerAccount import net.thunderbird.feature.mail.message.list.internal.domain.usecase.SetArchiveFolder +import net.thunderbird.feature.mail.message.list.internal.ui.MessageListScreenRenderer 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.ChangeSortCriteriaSideEffect -import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadFolderInformationSideEffect -import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadPreferencesSideEffect -import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSortCriteriaStateSideEffectHandler -import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSwipeActionsStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.inject.messageListSideEffectsModule import net.thunderbird.feature.mail.message.list.ui.MessageListContract -import net.thunderbird.feature.mail.message.list.ui.MessageListStateSideEffectHandlerFactory 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 import org.koin.dsl.module val featureMessageListModule = module { + includes(messageListSideEffectsModule) factory { GetAccountFolders(folderRepository = get()) } factory { CreateArchiveFolder( @@ -73,44 +69,9 @@ val featureMessageListModule = module { getDefaultSortCriteria = get(), ) } - factoryListOf( - { - LoadPreferencesSideEffect.Factory( - logger = get(), - getMessageListPreferences = get(), - ) - }, - { - LoadSwipeActionsStateSideEffectHandler.Factory( - logger = get(), - buildSwipeActions = get(), - ) - }, - { parameters -> - val args = parameters.get() - LoadSortCriteriaStateSideEffectHandler.Factory( - accounts = args.accountIds, - logger = get(), - getSortCriteriaPerAccount = get(), - ) - }, - { - ChangeSortCriteriaSideEffect.Factory( - logger = get(), - updateSortCriteria = get(), - ) - }, - { parameters -> - val args = parameters.get() - LoadFolderInformationSideEffect.Factory( - accountIds = args.accountIds, - folderId = args.folderId, - logger = get(), - folderRepository = get(), - ) - }, - ) - factory { MessageListStateMachine.Factory() } + factory { + MessageListStateMachine.Factory(logger = get(), clock = get(), debuggingSettingsPreferenceManager = get()) + } viewModel { parameters -> MessageListViewModel( logger = get(), @@ -119,4 +80,5 @@ val featureMessageListModule = module { ) } single { DefaultLocalDeleteOperationDecider() } + factory { MessageListScreenRenderer() } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRenderer.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRenderer.kt index c9194062bf6..d9b29af98db 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRenderer.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRenderer.kt @@ -35,7 +35,6 @@ import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.rememberSwipe import net.thunderbird.feature.mail.message.list.internal.ui.MessageListScreenRenderer.Companion.TEST_TAG_MESSAGE_LIST_ROOT import net.thunderbird.feature.mail.message.list.internal.ui.component.MessageItemSwipeBackground import net.thunderbird.feature.mail.message.list.internal.ui.component.MessageListItem -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.MessageItemEvent @@ -54,7 +53,6 @@ internal class MessageListScreenRenderer : MessageListContract.MessageListScreen state: MessageListState, dispatchEvent: (MessageListEvent) -> Unit, onEffect: (MessageListEffect) -> Unit, - preferences: MessageListPreferences, modifier: Modifier, inAppNotificationEventFilter: (InAppNotification) -> Boolean, ) { 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 index 11635ae0213..72e0b6a2cf2 100644 --- 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 @@ -1,12 +1,21 @@ package net.thunderbird.feature.mail.message.list.internal.ui.state.machine +import kotlin.time.Clock +import kotlin.time.ExperimentalTime import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.common.action.SwipeActions import net.thunderbird.core.common.state.StateMachine import net.thunderbird.core.common.state.builder.stateMachine import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.Logger +import net.thunderbird.core.preference.debugging.DebuggingSettingsPreferenceManager import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.Folder +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi import net.thunderbird.feature.mail.message.list.ui.state.MessageListState +private const val TAG = "MessageListStateMachine" + /** * Manages the state transitions for the message list UI. * @@ -25,9 +34,27 @@ import net.thunderbird.feature.mail.message.list.ui.state.MessageListState * @param stateMachine The underlying state machine implementation, configured with all possible states and transitions. */ class MessageListStateMachine( + private val logger: Logger, + private val clock: Clock, private val scope: CoroutineScope, private val dispatch: (MessageListEvent) -> Unit, + private val debuggingSettingsPreferenceManager: DebuggingSettingsPreferenceManager, private val stateMachine: StateMachine = stateMachine(scope) { + withLogger(logger, TAG) + if (debuggingSettingsPreferenceManager.getConfig().isDebugLoggingEnabled) { + enableDebug { + @OptIn(ExperimentalTime::class) + withClock(clock) + formatValues { obj, _ -> + when (obj) { + is MessageItemUi -> "id: ${obj.id}" + is SwipeActions -> "(l: ${obj.leftAction.name}, r: ${obj.rightAction.name})" + is Folder -> "(id: ${obj.id}, name: ${obj.name})" + else -> obj.toString() + } + } + } + } warmingUpInitialState(initialState = MessageListState.WarmingUp(), dispatch) globalState() loadingMessagesState() @@ -36,13 +63,18 @@ class MessageListStateMachine( searchingMessagesState() }, ) : StateMachine by stateMachine { - class Factory { - fun create( - scope: CoroutineScope, - dispatch: (MessageListEvent) -> Unit, - ): MessageListStateMachine = MessageListStateMachine( - scope = scope, - dispatch = dispatch, - ) + class Factory( + private val logger: Logger, + private val clock: Clock, + private val debuggingSettingsPreferenceManager: DebuggingSettingsPreferenceManager, + ) { + fun create(scope: CoroutineScope, dispatch: (MessageListEvent) -> Unit): MessageListStateMachine = + MessageListStateMachine( + logger = logger, + clock = clock, + scope = scope, + dispatch = dispatch, + debuggingSettingsPreferenceManager = debuggingSettingsPreferenceManager, + ) } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffect.kt new file mode 100644 index 00000000000..cca321a7249 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffect.kt @@ -0,0 +1,39 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.MessageListStateSideEffectHandlerFactory +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +/** + * A side effect handler that monitors the message list state and dispatches an event when all + * initial configurations have been loaded and the system is ready to proceed. + * + * @param logger A logger instance for tracking and debugging side effect execution. + * @param dispatch A suspend function that dispatches [MessageListEvent] instances to the state machine. + */ +class AllConfigurationsReadySideEffect( + 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) { + dispatch(MessageListEvent.AllConfigsReady) + } + + class Factory( + private val logger: Logger, + ) : MessageListStateSideEffectHandlerFactory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + ): StateSideEffectHandler = AllConfigurationsReadySideEffect( + dispatch = dispatch, + logger = logger, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/inject/MessageListSideEffectsModule.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/inject/MessageListSideEffectsModule.kt new file mode 100644 index 00000000000..c537f0c8acd --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/inject/MessageListSideEffectsModule.kt @@ -0,0 +1,57 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.inject + +import net.thunderbird.core.common.inject.factoryListOf +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.AllConfigurationsReadySideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ChangeSortCriteriaSideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadFolderInformationSideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadPreferencesSideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSortCriteriaStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.LoadSwipeActionsStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.MessageListContract +import net.thunderbird.feature.mail.message.list.ui.MessageListStateSideEffectHandlerFactory +import org.koin.dsl.module + +/** + * Dependency injection module that provides a list of side effect handler factories for the + * message list feature. + */ +internal val messageListSideEffectsModule = module { + factoryListOf( + { + LoadPreferencesSideEffect.Factory( + logger = get(), + getMessageListPreferences = get(), + ) + }, + { + LoadSwipeActionsStateSideEffectHandler.Factory( + logger = get(), + buildSwipeActions = get(), + ) + }, + { parameters -> + val args = parameters.get() + LoadSortCriteriaStateSideEffectHandler.Factory( + accounts = args.accountIds, + logger = get(), + getSortCriteriaPerAccount = get(), + ) + }, + { + ChangeSortCriteriaSideEffect.Factory( + logger = get(), + updateSortCriteria = get(), + ) + }, + { parameters -> + val args = parameters.get() + LoadFolderInformationSideEffect.Factory( + accountIds = args.accountIds, + folderId = args.folderId, + logger = get(), + folderRepository = get(), + ) + }, + { AllConfigurationsReadySideEffect.Factory(logger = get()) }, + ) +} 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..6fadced0f6d 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 @@ -1,9 +1,11 @@ package net.thunderbird.feature.mail.message.list.internal import kotlin.test.Test +import kotlin.time.Clock import net.thunderbird.core.common.resources.StringsResourceManager import net.thunderbird.core.logging.Logger import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.core.preference.debugging.DebuggingSettingsPreferenceManager import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogContract import org.koin.core.annotation.KoinExperimentalAPI import org.koin.test.KoinTest @@ -18,8 +20,10 @@ class FeatureMessageListModuleKtTest : KoinTest { featureMessageListModule.verify( extraTypes = listOf( Logger::class, + Clock::class, StringsResourceManager::class, GeneralSettingsManager::class, + DebuggingSettingsPreferenceManager::class, ), injections = listOf( definition(SetupArchiveFolderDialogContract.State::class), diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRendererTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRendererTest.kt index fa84144859f..20308edeaef 100644 --- a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRendererTest.kt +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenRendererTest.kt @@ -509,7 +509,6 @@ class MessageListScreenRendererTest : ComposeTest() { state = state, dispatchEvent = dispatchEvent, onEffect = onEffect, - preferences = preferences, modifier = Modifier.fillMaxSize(), ) } diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt index a5bdcdd3da1..2fe5676acc7 100644 --- a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/MessageListStateMachineTest.kt @@ -6,6 +6,7 @@ import assertk.all import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue import assertk.assertions.prop @@ -16,24 +17,34 @@ import kotlin.test.Test import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import net.thunderbird.core.common.action.SwipeAction import net.thunderbird.core.common.action.SwipeActions +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.preference.debugging.DebuggingSettings +import net.thunderbird.core.preference.debugging.DebuggingSettingsPreferenceManager import net.thunderbird.core.preference.display.visualSettings.message.list.MessageListDateTimeFormat import net.thunderbird.core.preference.display.visualSettings.message.list.UiDensity +import net.thunderbird.core.testing.TestClock import net.thunderbird.feature.account.AccountId import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.UnifiedAccountId +import net.thunderbird.feature.mail.folder.api.FolderType import net.thunderbird.feature.mail.message.list.domain.model.SortCriteria import net.thunderbird.feature.mail.message.list.domain.model.SortType import net.thunderbird.feature.mail.message.list.preferences.ActionRequiringUserConfirmation import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.event.FolderEvent 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.Account import net.thunderbird.feature.mail.message.list.ui.state.ComposedAddressUi +import net.thunderbird.feature.mail.message.list.ui.state.Folder import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi.State import net.thunderbird.feature.mail.message.list.ui.state.MessageListMetadata @@ -43,8 +54,11 @@ import net.thunderbird.feature.mail.message.list.ui.state.MessageListState @OptIn(ExperimentalCoroutinesApi::class) class MessageListStateMachineTest { private fun TestScope.createStateMachine(dispatch: (MessageListEvent) -> Unit = {}) = MessageListStateMachine( + logger = TestLogger(), + clock = TestClock(), scope = this, dispatch = dispatch, + debuggingSettingsPreferenceManager = FakeDebuggingSettingsPreferenceManager(), ) // region [WarmingUp state] @@ -141,9 +155,11 @@ class MessageListStateMachineTest { val stateMachine = createStateMachine() val preferences = createMessageListPreferences() val sortCriteriaPerAccount = mapOf(null to SortCriteria(SortType.DateDesc)) - val swipeActions = mapOf( + val swipeActions = mapOf( AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), ) + val expectedFolderId = "this-is-my-folder" + val folder = createFolder(id = expectedFolderId) advanceUntilIdle() // Act @@ -161,6 +177,10 @@ class MessageListStateMachineTest { stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) assertThat(awaitItem()).isInstanceOf() + + stateMachine.process(FolderEvent.FolderLoaded(folder = folder)) + assertThat(awaitItem()).isInstanceOf() + stateMachine.process(event = MessageListEvent.AllConfigsReady) assertThat(awaitItem()) .isInstanceOf() @@ -170,7 +190,10 @@ class MessageListStateMachineTest { transform { it.metadata }.all { prop(MessageListMetadata::swipeActions).isEqualTo(swipeActions) prop(MessageListMetadata::sortCriteriaPerAccount).isEqualTo(sortCriteriaPerAccount) - prop(MessageListMetadata::folder).isNull() + prop(MessageListMetadata::folder) + .isNotNull() + .prop(Folder::id) + .isEqualTo(expectedFolderId) } } @@ -292,7 +315,7 @@ class MessageListStateMachineTest { prop(MessageListState.LoadedMessages::preferences).isEqualTo(preferences) prop(MessageListState.LoadedMessages::messages).isEqualTo(messages) transform { it.metadata }.all { - prop(MessageListMetadata::folder).isNull() + prop(MessageListMetadata::folder).isNotNull() prop(MessageListMetadata::activeMessage).isNull() prop(MessageListMetadata::swipeActions).isEqualTo(swipeActions) prop(MessageListMetadata::sortCriteriaPerAccount).isEqualTo(sortCriteriaPerAccount) @@ -551,12 +574,14 @@ class MessageListStateMachineTest { swipeActions: Map = mapOf( AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), ), + folder: Folder = createFolder(), ): MessageListStateMachine { val stateMachine = createStateMachine() advanceUntilIdle() stateMachine.process(event = MessageListEvent.UpdatePreferences(preferences)) stateMachine.process(event = MessageListEvent.SortCriteriaLoaded(sortCriteriaPerAccount)) stateMachine.process(event = MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = FolderEvent.FolderLoaded(folder = folder)) stateMachine.process(event = MessageListEvent.AllConfigsReady) advanceUntilIdle() return stateMachine @@ -569,12 +594,14 @@ class MessageListStateMachineTest { swipeActions: Map = mapOf( AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), ), + folder: Folder = createFolder(), ): MessageListStateMachine { val stateMachine = createStateMachine() advanceUntilIdle() stateMachine.process(event = MessageListEvent.UpdatePreferences(preferences)) stateMachine.process(event = MessageListEvent.SortCriteriaLoaded(sortCriteriaPerAccount)) stateMachine.process(event = MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = FolderEvent.FolderLoaded(folder = folder)) stateMachine.process(event = MessageListEvent.AllConfigsReady) stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) @@ -589,12 +616,14 @@ class MessageListStateMachineTest { swipeActions: Map = mapOf( AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), ), + folder: Folder = createFolder(), ): MessageListStateMachine { val stateMachine = createStateMachine() advanceUntilIdle() stateMachine.process(MessageListEvent.UpdatePreferences(preferences)) stateMachine.process(MessageListEvent.SortCriteriaLoaded(sortCriteriaPerAccount)) stateMachine.process(MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = FolderEvent.FolderLoaded(folder = folder)) stateMachine.process(event = MessageListEvent.AllConfigsReady) stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) @@ -610,12 +639,14 @@ class MessageListStateMachineTest { swipeActions: Map = mapOf( AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), ), + folder: Folder = createFolder(), ): MessageListStateMachine { val stateMachine = createStateMachine() advanceUntilIdle() stateMachine.process(event = MessageListEvent.UpdatePreferences(preferences)) stateMachine.process(event = MessageListEvent.SortCriteriaLoaded(sortCriteriaPerAccount)) stateMachine.process(event = MessageListEvent.SwipeActionsLoaded(swipeActions)) + stateMachine.process(event = FolderEvent.FolderLoaded(folder = folder)) stateMachine.process(event = MessageListEvent.AllConfigsReady) stateMachine.process(event = MessageListEvent.UpdateLoadingProgress(progress = 1f)) stateMachine.process(event = MessageListEvent.MessagesLoaded(messages)) @@ -712,3 +743,37 @@ private fun createMessageUiItem( selected = selected, threadCount = threadCount, ) + +private fun createFolder( + id: String = "fake", + account: Account = Account(id = UnifiedAccountId, Color.Unspecified), + name: String = "unified", + type: FolderType = FolderType.INBOX, + parent: Folder? = null, + root: Folder? = null, + canExpunge: Boolean = false, +): Folder = Folder( + id = id, + account = account, + name = name, + type = type, + parent = parent, + root = root, + canExpunge = canExpunge, +) + +private class FakeDebuggingSettingsPreferenceManager( + private val enabledDebug: Boolean = true, +) : DebuggingSettingsPreferenceManager { + override fun save(config: DebuggingSettings) { + TODO("Not yet implemented") + } + + override fun getConfig(): DebuggingSettings = DebuggingSettings( + isDebugLoggingEnabled = enabledDebug, + isSyncLoggingEnabled = false, + isSensitiveLoggingEnabled = false, + ) + + override fun getConfigFlow(): Flow = flowOf(getConfig()) +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffectTest.kt new file mode 100644 index 00000000000..0a5b30c481e --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/AllConfigurationsReadySideEffectTest.kt @@ -0,0 +1,167 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import androidx.compose.ui.graphics.Color +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import dev.mokkery.spy +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.action.SwipeAction +import net.thunderbird.core.common.action.SwipeActions +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.core.preference.display.visualSettings.message.list.MessageListDateTimeFormat +import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.UnifiedAccountId +import net.thunderbird.feature.mail.folder.api.FolderType +import net.thunderbird.feature.mail.message.list.domain.model.SortCriteria +import net.thunderbird.feature.mail.message.list.domain.model.SortType +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.Account +import net.thunderbird.feature.mail.message.list.ui.state.Folder +import net.thunderbird.feature.mail.message.list.ui.state.MessageListMetadata +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +class AllConfigurationsReadySideEffectTest { + + @Test + fun `accept() should return true when state is WarmingUp and isReady is true`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.accept( + event = MessageListEvent.SwipeActionsLoaded(persistentMapOf()), + newState = createReadyWarmingUpState(), + ) + + // Assert + assertThat(result).isTrue() + } + + @Test + fun `accept() should return false when state is WarmingUp but metadata is not ready`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.accept( + event = MessageListEvent.LoadConfigurations, + newState = MessageListState.WarmingUp(), + ) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `accept() should return false when state is WarmingUp but preferences is null`() = runTest { + // Arrange + val testSubject = createTestSubject() + val state = createReadyWarmingUpState().copy(preferences = null) + + // Act + val result = testSubject.accept( + event = MessageListEvent.UpdatePreferences(createPreferences()), + newState = state, + ) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `accept() should return false when state is not WarmingUp`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.accept( + event = MessageListEvent.AllConfigsReady, + newState = MessageListState.LoadedMessages( + metadata = createReadyMetadata(), + preferences = createPreferences(), + messages = kotlinx.collections.immutable.persistentListOf(), + ), + ) + + // Assert + assertThat(result).isFalse() + } + + @Test + fun `handle() should dispatch AllConfigsReady event`() = runTest { + // Arrange + val dispatch = spy Unit>(obj = {}) + val testSubject = createTestSubject(dispatch = dispatch) + val state = createReadyWarmingUpState() + + // Act + testSubject.handle(oldState = MessageListState.WarmingUp(), newState = state) + + // Assert + verifySuspend { dispatch(MessageListEvent.AllConfigsReady) } + } + + @Test + fun `factory should create AllConfigurationsReadySideEffect`() = runTest { + // Arrange + val factory = AllConfigurationsReadySideEffect.Factory( + logger = TestLogger(), + ) + + // Act + val result = factory.create( + scope = this, + dispatch = {}, + ) + + // Assert + assertThat(result).isInstanceOf(AllConfigurationsReadySideEffect::class) + } + + private fun createTestSubject( + dispatch: suspend (MessageListEvent) -> Unit = {}, + ) = AllConfigurationsReadySideEffect( + logger = TestLogger(), + dispatch = dispatch, + ) + + private fun createReadyWarmingUpState() = MessageListState.WarmingUp( + metadata = createReadyMetadata(), + preferences = createPreferences(), + ) + + private fun createReadyMetadata() = MessageListMetadata( + folder = Folder( + id = "fake", + account = Account(id = UnifiedAccountId, color = Color.Unspecified), + name = "Inbox", + type = FolderType.INBOX, + ), + swipeActions = persistentMapOf( + AccountIdFactory.create() to SwipeActions(SwipeAction.None, SwipeAction.None), + ), + sortCriteriaPerAccount = persistentMapOf(null to SortCriteria(primary = SortType.DateDesc)), + activeMessage = null, + isActive = false, + ) + + private fun createPreferences() = MessageListPreferences( + density = net.thunderbird.core.preference.display.visualSettings.message.list.UiDensity.Default, + groupConversations = false, + showCorrespondentNames = false, + showMessageAvatar = false, + showFavouriteButton = false, + senderAboveSubject = false, + excerptLines = 1, + dateTimeFormat = MessageListDateTimeFormat.Contextual, + actionRequiringUserConfirmation = persistentSetOf(), + colorizeBackgroundWhenRead = false, + ) +} 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 05535756d35..553ca50c476 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 @@ -5,7 +5,6 @@ import android.app.SearchManager import android.content.Context import android.content.Intent import android.content.res.ColorStateList -import android.os.Build import android.os.Bundle import android.os.SystemClock import android.view.LayoutInflater @@ -24,7 +23,6 @@ import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -45,7 +43,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.platform.compositionContext @@ -61,9 +58,7 @@ 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.FragmentActivity import androidx.fragment.app.setFragmentResultListener @@ -73,11 +68,8 @@ import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.recyclerview.widget.RecyclerView import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal import app.k9mail.core.ui.compose.designsystem.atom.Surface import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge @@ -88,12 +80,9 @@ 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.MessageSearchActivity -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 @@ -113,7 +102,6 @@ import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.AR import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.ARG_THREADED_LIST import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_ACTIVE_MESSAGE import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_ACTIVE_MESSAGES -import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_MESSAGE_LIST_APPEARANCE import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_REMOTE_SEARCH_PERFORMED import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_SEARCH_VIEW_ICONIFIED import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.STATE_SEARCH_VIEW_QUERY @@ -121,7 +109,6 @@ import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.Companion.ST import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.MessageListFragmentListener import com.fsck.k9.ui.messagelist.MessageListFragmentBridgeContract.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.button.MaterialButton import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -129,20 +116,14 @@ 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.ExperimentalTime import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.jcip.annotations.GuardedBy import net.thunderbird.core.android.account.Expunge @@ -150,17 +131,11 @@ 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.network.ConnectivityManager -import net.thunderbird.core.common.action.SwipeAction -import net.thunderbird.core.common.action.SwipeActions 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.compose.designsystem.atom.ClickableSurface import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icon @@ -168,28 +143,23 @@ import net.thunderbird.core.ui.compose.designsystem.atom.icon.Icons import net.thunderbird.core.ui.compose.theme2.MainTheme import net.thunderbird.core.ui.contract.mvi.observeWithoutEffect import net.thunderbird.core.ui.theme.api.FeatureThemeProvider -import net.thunderbird.feature.account.AccountId import net.thunderbird.feature.account.AccountIdFactory -import net.thunderbird.feature.account.avatar.AvatarMonogramCreator +import net.thunderbird.feature.account.UnifiedAccountId import net.thunderbird.feature.mail.folder.api.OutboxFolderManager import net.thunderbird.feature.mail.message.list.domain.model.SortCriteria import net.thunderbird.feature.mail.message.list.domain.model.SortType import net.thunderbird.feature.mail.message.list.extension.toDomainSortType -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.dialog.SetupArchiveFolderDialogFragmentFactory 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.MessageListMetadata +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState 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 @@ -225,6 +195,8 @@ class MessageListFragment : val logTag: String = TAG override val fragmentActivity: FragmentActivity? get() = activity + private val messageListScreenRenderer: MessageListContract.MessageListScreenRenderer by inject() + // region [ LegacyMessageListFragment properties ] override val legacyViewModel: MessageListViewModel by viewModel() private val recentChangesViewModel: RecentChangesViewModel by viewModel() @@ -238,8 +210,6 @@ class MessageListFragment : private val connectivityManager: ConnectivityManager by inject() private val localStoreProvider: LocalStoreProvider by inject() - private val setupArchiveFolderDialogFragmentFactory: SetupArchiveFolderDialogFragmentFactory 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() @@ -250,9 +220,6 @@ class MessageListFragment : 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 -> @@ -270,13 +237,8 @@ class MessageListFragment : 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 var searchView: SearchView? = null private var initialSearchViewQuery: String? = null private var initialSearchViewIconified = true @@ -334,8 +296,6 @@ class MessageListFragment : 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 @@ -350,9 +310,6 @@ class MessageListFragment : maybeHideFloatingActionButton() } - private lateinit var messageListAppearance: MessageListAppearance - private var pendingMessageListInfo: MessageListInfo? = null - private var pendingAdapterDependentFunctionExecution = mutableListOf<() -> Unit>() // endregion [ LegacyMessageListFragment properties ] // region [ LegacyMessageListFragment methods] @@ -389,7 +346,7 @@ class MessageListFragment : return } - generalSettingsManager.getSettingsFlow() + generalSettingsManager.getConfigFlow() /** * Skips the first emitted item from the settings flow, * since the initial value of `showingThreadedList` is taken @@ -418,18 +375,6 @@ class MessageListFragment : initialSearchViewIconified = savedInstanceState.getBoolean(STATE_SEARCH_VIEW_ICONIFIED, true) val messageReferenceString = savedInstanceState.getString(STATE_ACTIVE_MESSAGE) activeMessage = MessageReference.parse(messageReferenceString) - - messageListAppearance = requireNotNull( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - savedInstanceState.getParcelable(STATE_MESSAGE_LIST_APPEARANCE, MessageListAppearance::class.java) - } else { - @Suppress("DEPRECATION") - savedInstanceState.getParcelable(STATE_MESSAGE_LIST_APPEARANCE) - }, - ) { - "Could not restore MessageListAppearance. Missing parcelable extra '$STATE_MESSAGE_LIST_APPEARANCE'. " + - "Extras: $savedInstanceState" - } } private fun restoreSelectedMessages(savedInstanceState: Bundle) { @@ -475,27 +420,21 @@ class MessageListFragment : 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, - 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 -> + inflater.inflate(R.layout.new_message_list_fragment, container, false).also { view -> + view.findViewById(R.id.message_list_compose_view).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + featureThemeProvider.WithTheme { + messageListScreenRenderer.Render( + onEffect = {}, + inAppNotificationEventFilter = ::filterInAppNotificationEvents, + viewModel = viewModel, + ) + } + } + } setFragmentResultListener( SetupArchiveFolderDialogFragmentFactory.RESULT_CODE_DISMISS_REQUEST_KEY, ) { key, bundle -> @@ -512,27 +451,8 @@ class MessageListFragment : } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(state = Lifecycle.State.CREATED) { - viewModel - .state - .mapNotNull { state -> state.preferences?.toMessageListAppearance() } - .distinctUntilChanged() - .collectLatest { appearance -> - messageListAppearance = appearance - if (recyclerView == null) { - initializeRecyclerView(requireView()) - } - } - } - } - legacyViewModel.getMessageListLiveData().observe(viewLifecycleOwner) { messageListInfo: MessageListInfo -> - if (::adapter.isInitialized) { - setMessageList(messageListInfo) - } else { - pendingMessageListInfo = messageListInfo - } + setMessageList(messageListInfo) } val menuHost: MenuHost = requireActivity() @@ -601,7 +521,6 @@ class MessageListFragment : } private fun initializeMessageListLayout(view: View) { - initializeSwipeRefreshLayout(view) initializeFloatingActionButton(view) initializeRecentChangesSnackbar() @@ -611,21 +530,6 @@ class MessageListFragment : 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 @@ -686,70 +590,6 @@ class MessageListFragment : floatingActionButton.isGone = true } - private fun initializeRecyclerView(view: View) { - adapter = createMessageListAdapter() - pendingMessageListInfo?.let { messageListInfo -> - setMessageList(messageListInfo) - pendingMessageListInfo = null - } - - val recyclerView = view.findViewById(R.id.message_list) - - if (!isShowFloatingActionButton) { - recyclerView.setPadding(0) - } - - recyclerView.layoutManager = LinearLayoutManager() - recyclerView.itemAnimator = MessageListItemAnimator() - - val itemTouchHelper = ItemTouchHelper( - MessageListSwipeCallback( - context = requireContext(), - scope = lifecycleScope, - resourceProvider = SwipeResourceProvider(requireContext()), - swipeActionSupportProvider = swipeActionSupportProvider, - swipeActions = swipeActions, - 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 - if (pendingAdapterDependentFunctionExecution.isNotEmpty()) { - logger.debug(logTag) { "Executing pending adapter dependent functions" } - pendingAdapterDependentFunctionExecution.forEach { it() } - pendingAdapterDependentFunctionExecution.clear() - } - } - private fun requireCoordinatorLayout(): CoordinatorLayout { val coordinatorLayout = coordinatorLayout ?: requireView().findViewById(R.id.message_list_coordinator) @@ -758,27 +598,6 @@ class MessageListFragment : 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) { @@ -861,17 +680,7 @@ class MessageListFragment : legacyViewModel.loadMessageList(config, forceUpdate) } - private fun executeOnlyAfterAdapterIsReady(function: () -> Unit) { - if (::adapter.isInitialized.not()) { - pendingAdapterDependentFunctionExecution.add { - function() - } - } else { - function() - } - } - - override fun folderLoading(folderId: Long, loading: Boolean) = executeOnlyAfterAdapterIsReady { + override fun folderLoading(folderId: Long, loading: Boolean) { currentFolder?.let { if (it.databaseId == folderId) { it.loading = loading @@ -929,10 +738,6 @@ class MessageListFragment : } override fun progress(progress: Boolean) { - if (!progress) { - swipeRefreshLayout?.isRefreshing = false - } - fragmentListener.setMessageListProgressEnabled(progress) } @@ -978,7 +783,7 @@ class MessageListFragment : val clickTime = SystemClock.elapsedRealtime() if (clickTime - lastMessageClick < MINIMUM_CLICK_INTERVAL) return - if (adapter.selectedCount > 0) { + if (selectedMessagesCount > 0) { toggleMessageSelect(messageListItem) } else { lastMessageClick = clickTime @@ -990,14 +795,12 @@ class MessageListFragment : } } + private fun Int?.orZero(): Int = this ?: 0 + override fun onDestroyView() { coordinatorLayout = null - recyclerView = null messageListSwipeCallback = null - itemTouchHelper = null - swipeRefreshLayout = null floatingActionButton = null - pendingAdapterDependentFunctionExecution.clear() if (isNewMessagesView && !requireActivity().isChangingConfigurations) { account?.id?.let { messagingController.clearNewMessages(it) } @@ -1011,9 +814,8 @@ class MessageListFragment : if (error != null) return - if (::adapter.isInitialized) { - outState.putLongArray(STATE_SELECTED_MESSAGES, adapter.selected.toLongArray()) - } + // TODO(#10775): Check if this will be needed. + // outState.putLongArray(STATE_SELECTED_MESSAGES, adapter.selected.toLongArray()) outState.putBoolean(STATE_REMOTE_SEARCH_PERFORMED, isRemoteSearch) searchView?.let { searchView -> outState.putString(STATE_SEARCH_VIEW_QUERY, searchView.query.toString()) @@ -1026,9 +828,6 @@ class MessageListFragment : if (activeMessage != null) { outState.putString(STATE_ACTIVE_MESSAGE, activeMessage!!.toIdentityString()) } - if (::messageListAppearance.isInitialized) { - outState.putParcelable(STATE_MESSAGE_LIST_APPEARANCE, messageListAppearance) - } } private fun getFolderInfoHolder(account: LegacyAccount, folderId: Long): FolderInfoHolder { @@ -1078,7 +877,6 @@ class MessageListFragment : val queryString = localSearch.remoteSearchArguments isRemoteSearch = true - swipeRefreshLayout?.isEnabled = false val account = this.account ?: return @@ -1557,25 +1355,16 @@ class MessageListFragment : } override 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 + // TODO(#10778): trigger update footer text event. } private fun selectAll() { - if (adapter.viewItems.isEmpty()) { + if (viewModel.state.value.messages.isEmpty()) { // Nothing to do if there are no messages return } - adapter.selectAll() + // TODO(#10775): trigger select all event here. if (actionMode == null) { startAndPrepareActionMode() @@ -1585,27 +1374,20 @@ class MessageListFragment : 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) + // TODO(#10775): Remove the unused suppression. + private fun toggleMessageSelect(@Suppress("unused") messageListItem: MessageListItem) { + // TODO(#10775): trigger message toggle select event. updateAfterSelectionChange() } + // TODO(#10775): Verify if message is selected. Also remove the unused suppression. + @Suppress("unused", "FunctionOnlyReturningConstant") private fun isMessageSelected(messageListItem: MessageListItem): Boolean { - return adapter.isSelected(messageListItem) + return false } private fun updateAfterSelectionChange() { - if (adapter.selectedCount == 0) { + if (selectedMessagesCount == 0) { actionMode?.finish() actionMode = null return @@ -1629,19 +1411,21 @@ class MessageListFragment : private fun updateActionMode() { val actionMode = actionMode ?: error("actionMode == null") - actionMode.title = getString(R.string.actionbar_selected, adapter.selectedCount) - actionModeCallback.showSelectAll(!adapter.isAllSelected) + val isAllSelected = stateSnapshot.messages.size == selectedMessagesCount + actionMode.title = getString(R.string.actionbar_selected, selectedMessagesCount) + actionModeCallback.showSelectAll(!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) + // TODO(#10775): Verify if this method is still needed. +// 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) { @@ -1658,24 +1442,24 @@ class MessageListFragment : } private fun setFlagForSelected(flag: Flag, newState: Boolean) { - if (adapter.selected.isEmpty()) return + if (selectedMessagesCount == 0) 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) - } - } + // TODO(#10775): apply the flag to each selected message. +// 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 -> @@ -1933,7 +1717,7 @@ class MessageListFragment : R.id.dialog_confirm_delete -> { onDeleteConfirmed(activeMessages!!) activeMessage = null - adapter.activeMessage = null + // TODO(#10775): trigger event to clean the active message from the state. } R.id.dialog_confirm_mark_all_as_read -> { @@ -1952,28 +1736,10 @@ class MessageListFragment : 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) } @@ -2037,14 +1803,13 @@ class MessageListFragment : 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) + // TODO(#10775): return adapter.getItemById(viewHolder.uniqueId) + return null } private val selectedMessages: List - get() = adapter.selectedMessages.map { it.messageReference } + // TODO(#10775): get() = adapter.selectedMessages.map { it.messageReference } + get() = emptyList() override fun onDelete() { selectedMessage?.let { message -> @@ -2154,35 +1919,19 @@ class MessageListFragment : 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() + val strippedSubject = messageListItems.first().subject?.let { Utility.stripSubject(it) } + threadTitle = if (strippedSubject.isNullOrEmpty()) { + getString(R.string.general_no_subject) } else { - // TODO: empty thread view -> return to full message list - } - } - - adapter.viewItems = buildList { - if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications).isEnabled()) { - add(MessageListViewItem.InAppNotificationBannerList) + strippedSubject } - addAll(messageListItems.map { MessageListViewItem.Message(it) }) + updateTitle() } rememberedSelected?.let { rememberedSelected = null - adapter.restoreSelected(it) + // TODO(#10775): restore the selected message } messageListItems @@ -2206,7 +1955,7 @@ class MessageListFragment : private fun resetActionMode() { if (!isResumed) return - if (!isActive || adapter.selected.isEmpty()) { + if (!isActive || selectedMessagesCount == 0) { actionMode?.finish() actionMode = null return @@ -2243,13 +1992,14 @@ class MessageListFragment : } // Redraw list immediately - if (::adapter.isInitialized) { - adapter.activeMessage = activeMessage - - if (messageReference != null) { - scrollToMessage(messageReference) - } - } + // TODO(#10775): Verify if the below code is still required. +// if (::adapter.isInitialized) { +// adapter.activeMessage = activeMessage +// +// if (messageReference != null) { +// scrollToMessage(messageReference) +// } +// } } override fun onFullyActive() { @@ -2269,6 +2019,7 @@ class MessageListFragment : // 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. + // TODO(#10775): This whole method may get deleted once we integrate the sort types using the new state. private fun rememberSortOverride(messageReference: MessageReference?) { val messageSortOverrides = legacyViewModel.messageSortOverrides @@ -2279,33 +2030,20 @@ class MessageListFragment : if (sortType != LegacySortType.SORT_UNREAD && sortType != LegacySortType.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) - } + // TODO(#10775): Verify if the below code is still required. +// 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 val isMarkAllAsReadSupported: Boolean @@ -2329,118 +2067,6 @@ class MessageListFragment : 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 && @@ -2663,7 +2289,7 @@ class MessageListFragment : } private val accountUuidsForSelected: Set - get() = adapter.selectedMessages.mapToSet { it.account.uuid } + get() = viewModel.state.value.messages.filter { it.selected }.mapToSet { it.account.id.toString() } override fun onDestroyActionMode(mode: ActionMode) { actionMode = null @@ -2673,7 +2299,7 @@ class MessageListFragment : flag = null unflag = null - adapter.clearSelected() + // TODO(#10775): clear the current selected messages here, if needed. } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { @@ -2846,16 +2472,14 @@ class MessageListFragment : parametersOf(args) } + val stateSnapshot get() = viewModel.state.value + + val selectedMessagesCount + get() = (viewModel.state.value as? MessageListState.SelectingMessages)?.selectedCount.orZero() + internal val MessageListMetadata.currentSortCriteria: SortCriteria get() = - sortCriteriaPerAccount.getValue(folder?.account?.id) - - val swipeActions: StateFlow> by lazy { - viewModel - .state - .map { it.metadata.swipeActions } - .stateIn(lifecycleScope, SharingStarted.Lazily, emptyMap()) - } + sortCriteriaPerAccount.getValue(folder?.account?.id?.takeIf { it != UnifiedAccountId }) private fun showComposeDropdown(anchor: View, lifecycleOwner: LifecycleOwner, stateOwner: SavedStateRegistryOwner) { val context = anchor.context @@ -2942,18 +2566,6 @@ class MessageListFragment : } } - private fun MessageListPreferences.toMessageListAppearance(): MessageListAppearance = MessageListAppearance( - previewLines = excerptLines, - stars = showFavouriteButton, - senderAboveSubject = senderAboveSubject, - showContactPicture = showMessageAvatar, - showingThreadedList = groupConversations, - backGroundAsReadIndicator = colorizeBackgroundWhenRead, - showAccountIndicator = isShowAccountIndicator, - density = density, - dateTimeFormat = dateTimeFormat, - ) - companion object Factory : MessageListFragmentBridgeContract.Factory { override fun newInstance( search: LocalMessageSearch, diff --git a/legacy/ui/legacy/src/main/res/layout/new_message_list_fragment.xml b/legacy/ui/legacy/src/main/res/layout/new_message_list_fragment.xml new file mode 100644 index 00000000000..b4f00eb90ac --- /dev/null +++ b/legacy/ui/legacy/src/main/res/layout/new_message_list_fragment.xml @@ -0,0 +1,30 @@ + + + + + + + +