diff --git a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/NewMessageItemPreview.kt b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/NewMessageItemPreview.kt index be5495d2a04..bbf10313395 100644 --- a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/NewMessageItemPreview.kt +++ b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/NewMessageItemPreview.kt @@ -161,6 +161,7 @@ private fun PreviewDefault( state = MessageItemUi( state = MessageItemUi.State.New, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -209,6 +210,7 @@ private fun PreviewCompact( state = MessageItemUi( state = MessageItemUi.State.New, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -257,6 +259,7 @@ private fun PreviewRelaxed( state = MessageItemUi( state = MessageItemUi.State.New, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -305,6 +308,7 @@ private fun PreviewDefaultWithoutAccountIndicator( state = MessageItemUi( state = MessageItemUi.State.New, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, diff --git a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/ReadMessageItemPreview.kt b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/ReadMessageItemPreview.kt index 1a209ebc3f3..140e94fda24 100644 --- a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/ReadMessageItemPreview.kt +++ b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/ReadMessageItemPreview.kt @@ -192,6 +192,7 @@ private fun PreviewDefault( state = MessageItemUi( state = MessageItemUi.State.Read, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -240,6 +241,7 @@ private fun PreviewCompact( state = MessageItemUi( state = MessageItemUi.State.Read, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -288,6 +290,7 @@ private fun PreviewRelaxed( state = MessageItemUi( state = MessageItemUi.State.Read, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -336,6 +339,7 @@ private fun PreviewDefaultWithoutAccountIndicator( state = MessageItemUi( state = MessageItemUi.State.Read, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, diff --git a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/UnreadMessageItemPreview.kt b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/UnreadMessageItemPreview.kt index 57a524ae638..83e2f9ee8c1 100644 --- a/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/UnreadMessageItemPreview.kt +++ b/feature/mail/message/list/api/src/debug/kotlin/net/thunderbird/feature/mail/message/list/ui/component/organism/UnreadMessageItemPreview.kt @@ -161,6 +161,7 @@ private fun PreviewDefault( state = MessageItemUi( state = MessageItemUi.State.Unread, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -209,6 +210,7 @@ private fun PreviewCompact( state = MessageItemUi( state = MessageItemUi.State.Unread, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -257,6 +259,7 @@ private fun PreviewRelaxed( state = MessageItemUi( state = MessageItemUi.State.Unread, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, @@ -305,6 +308,7 @@ private fun PreviewDefaultWithoutIndicator( state = MessageItemUi( state = MessageItemUi.State.Unread, id = "", + messageReference = "reference", account = Account(id = AccountIdFactory.create(), color = params.accountColor), senders = ComposedAddressUi( displayName = params.sender, 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 268660c7ef2..5d168b55e33 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 @@ -12,6 +12,7 @@ import net.thunderbird.feature.mail.message.list.ui.component.MessageListScope import net.thunderbird.feature.mail.message.list.ui.component.rememberMessageListScope 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.legacy.LegacyMessageListBridge import net.thunderbird.feature.mail.message.list.ui.state.MessageListState import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory import net.thunderbird.feature.notification.api.content.InAppNotification @@ -48,6 +49,8 @@ interface MessageListContract { data class Args( val accountIds: Set, val folderId: Long?, + // Temporary argument just to allow using the current legacy implementation. + val legacyMessageListBridge: LegacyMessageListBridge, ) } 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 78808a80303..16adaccaec6 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 @@ -1,6 +1,7 @@ package net.thunderbird.feature.mail.message.list.ui.effect import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi import net.thunderbird.feature.mail.message.list.ui.state.MessageListState /** @@ -81,4 +82,22 @@ sealed interface MessageListEffect { * that have been discarded for that account. */ data class DraftsDiscarded(val messagesIdByAccountId: Map>) : MessageListEffect + + /** + * Effect to update the state and appearance of the contextual action mode toolbar. + * + * @param title The text to be displayed in the action mode toolbar, usually indicating + * the number of currently selected messages. + * @param isAllSelected Whether all available messages in the current list are selected. + */ + data class UpdateToolbarActionMode( + val title: String, + val isAllSelected: Boolean, + ) : MessageListEffect + + data object ResetToolbarActionMode : MessageListEffect + + data class ScrollToMessage(val message: MessageItemUi) : MessageListEffect + + data class OpenMessage(val message: MessageItemUi) : MessageListEffect } diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageItemEvent.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageItemEvent.kt index f50614b30bd..edab75e2cb6 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageItemEvent.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/event/MessageItemEvent.kt @@ -17,6 +17,9 @@ sealed interface MessageItemEvent : MessageListEvent.UserEvent { */ data class OnMessageClick(val message: MessageItemUi) : MessageItemEvent + data object SelectAll : MessageItemEvent + data object DeselectAll : MessageItemEvent + /** * Event to toggle the selection state of one or more messages. * @@ -60,4 +63,9 @@ sealed interface MessageItemEvent : MessageListEvent.UserEvent { * @property message The message item that was swiped. */ data class OnSwipeMessage(val message: MessageItemUi, val swipeAction: SwipeAction) : MessageItemEvent + + data class OnFocusEnter(val message: MessageItemUi) : MessageItemEvent + data class OnFocusExit(val message: MessageItemUi) : MessageItemEvent + + data class SetMessageActive(val message: MessageItemUi?) : MessageItemEvent } 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 4de15492209..0c494a22635 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 @@ -56,7 +56,7 @@ sealed interface MessageListEvent { * * @param progress A float value between 0.0 and 1.0 representing the loading completion percentage. */ - data class UpdateLoadingProgress(val progress: Float) : SystemEvent + data class UpdateLoadingProgress(val progress: Float, val messages: List = emptyList()) : SystemEvent /** * A system event indicating that a list of messages has been successfully loaded. diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/legacy/LegacyMessageListBridge.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/legacy/LegacyMessageListBridge.kt new file mode 100644 index 00000000000..bf962bd6ce3 --- /dev/null +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/legacy/LegacyMessageListBridge.kt @@ -0,0 +1,27 @@ +package net.thunderbird.feature.mail.message.list.ui.legacy + +import kotlinx.coroutines.flow.Flow +import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi +import net.thunderbird.feature.mail.message.list.ui.state.MessageListMetadata + +/** + * Bridge interface for accessing functionalities implemented in the legacy message list system. + * + * @see MessageListPreferences for available display and behavior customization options + * @see MessageListMetadata for contextual information about the message list state + * @see MessageItemUi for the structure of individual message items in the returned list + */ +interface LegacyMessageListBridge { + /** + * Loads messages for the current mailbox stored in the database. + * + * @param preferences Display and behaviour settings that influence how messages are loaded. + * @param metadata Contextual information about the message list (e.g. folder, account). + * @returns a [Flow] that emits updated [MessageItemUi] lists as the underlying data changes. + */ + fun loadMessages( + preferences: MessageListPreferences, + metadata: MessageListMetadata, + ): Flow> +} diff --git a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageItemUi.kt b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageItemUi.kt index 620a0940f2d..fc3f5da6b9f 100644 --- a/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageItemUi.kt +++ b/feature/mail/message/list/api/src/main/kotlin/net/thunderbird/feature/mail/message/list/ui/state/MessageItemUi.kt @@ -1,6 +1,7 @@ package net.thunderbird.feature.mail.message.list.ui.state import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.toPersistentList /** * Represents the UI state of a single message item in a message list. @@ -12,6 +13,7 @@ import androidx.compose.runtime.Immutable * * @property state The current display state of the message (e.g., Read, Unread, Selected). * @property id The unique identifier for the message. + * @param messageReference The reference to the message in the underlying data source. * @property account The account to which this message belongs. * @property senders The composed representation of the message sender(s) with display name, * styling, and avatar. @@ -32,6 +34,7 @@ import androidx.compose.runtime.Immutable data class MessageItemUi( val state: State, val id: String, + val messageReference: String, val account: Account, val senders: ComposedAddressUi, val subject: String, @@ -63,3 +66,29 @@ data class MessageItemUi( Unread, } } + +/** + * Create a copy of this message item with the updated [state] and refreshed sender + * display name styling. + * + * @param state The new state to apply. + * @return A new [MessageItemUi] instance with the updated state and styling. + */ +fun MessageItemUi.withState(state: MessageItemUi.State): MessageItemUi { + val styles = buildList { + when (val separatorIndex = senders.displayName.indexOf(',')) { + -1 if state != MessageItemUi.State.Read -> add(ComposedAddressStyle.Bold(start = 0)) + in 0..Int.MAX_VALUE if state != MessageItemUi.State.Read -> { + add(ComposedAddressStyle.Bold(start = 0, end = separatorIndex)) + add(ComposedAddressStyle.Regular(start = separatorIndex)) + } + + else -> add(ComposedAddressStyle.Regular(start = 0)) + } + }.toPersistentList() + + return copy( + senders = senders.copy(displayNameStyles = styles), + state = state, + ) +} 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 26e825e69e3..211fbd50772 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 @@ -31,8 +31,9 @@ data class MessageListMetadata( val folder: Folder?, val swipeActions: ImmutableMap, val sortCriteriaPerAccount: ImmutableMap, - val activeMessage: MessageItemUi?, val isActive: Boolean, + val activeMessage: MessageItemUi? = null, + val focusedMessage: MessageItemUi? = null, val availablePrimarySortTypes: ImmutableSet = SortType.entries.toPersistentSet(), val availableSecondarySortTypes: ImmutableSet = SortCriteria.DateSortTypeOnly.toPersistentSet(), val footer: MessageListFooter = MessageListFooter(), 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 67bfa01ba33..60dcfc5280a 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableList import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences /** @@ -74,6 +75,26 @@ sealed interface MessageListState { is WarmingUp -> copy(preferences = preferences?.transform()) } + /** + * Creates a copy of the current state with an updated list of [messages], preserving all other + * properties. + * + * @param transform A lambda function that receives the current [ImmutableList] of [MessageItemUi] + * and returns a new, transformed list. + * @return A new [MessageListState] instance of the same type as the original, containing the + * transformed messages. + */ + fun mapMessages(transform: (MessageItemUi) -> MessageItemUi): MessageListState { + val messages = messages.map(transform).toImmutableList() + return when (this) { + is LoadedMessages -> copy(messages = messages) + is LoadingMessages -> copy(messages = messages) + is SearchingMessages -> copy(messages = messages) + is SelectingMessages -> copy(messages = messages) + is WarmingUp -> copy(messages = messages) + } + } + /** * Represents the initial state of the message list screen before any messages are loaded. * diff --git a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/preview/MessagePreviewHelper.kt b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/preview/MessagePreviewHelper.kt index a2e11893c16..94ec4ad91c3 100644 --- a/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/preview/MessagePreviewHelper.kt +++ b/feature/mail/message/list/internal/src/debug/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/preview/MessagePreviewHelper.kt @@ -42,6 +42,7 @@ internal object MessagePreviewHelper { forwarded = forwarded, selected = selected, threadCount = threadCount, + messageReference = "reference", ) val sampleMessages = persistentListOf( diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/DefaultLocalDeleteOperationDecider.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/DefaultLocalDeleteOperationDecider.kt index e5c951b05c9..cf1a9c9f6ad 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/DefaultLocalDeleteOperationDecider.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/DefaultLocalDeleteOperationDecider.kt @@ -3,7 +3,7 @@ package net.thunderbird.feature.mail.message.list.internal import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.feature.mail.message.list.LocalDeleteOperationDecider -class DefaultLocalDeleteOperationDecider : LocalDeleteOperationDecider { +internal class DefaultLocalDeleteOperationDecider : LocalDeleteOperationDecider { override fun isDeleteImmediately( account: LegacyAccountDto, folderId: Long, 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 566d8fb9c16..845466fa87d 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 @@ -77,6 +77,7 @@ val featureMessageListModule = module { logger = get(), messageListStateMachineFactory = get(), stateSideEffectHandlersFactories = getList { parameters }, + stringsResourceManager = get(), ) } single { DefaultLocalDeleteOperationDecider() } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenAccessibilityState.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenAccessibilityState.kt index f82d6e7047d..40ef8c9ef5b 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenAccessibilityState.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/MessageListScreenAccessibilityState.kt @@ -30,7 +30,7 @@ import net.thunderbird.feature.mail.message.list.R as ApiR * swipe actions are available. */ @Stable -class MessageListScreenAccessibilityState( +internal class MessageListScreenAccessibilityState( private val stateDescription: Map, val swipeDirectionAccessibilityAction: ImmutableList = persistentListOf(), ) { @@ -97,7 +97,9 @@ enum class MessageListStateDescription { * description strings or swipe actions change. */ @Composable -fun rememberMessageListScreenAccessibilityState(swipeActions: SwipeActions?): MessageListScreenAccessibilityState { +internal fun rememberMessageListScreenAccessibilityState( + swipeActions: SwipeActions?, +): MessageListScreenAccessibilityState { val stateDescription = mapOf( MessageListStateDescription.NewMessage to stringResource( id = R.string.message_list_state_new_message_description, 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 index c8e776d0997..7029fc8662c 100644 --- 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 @@ -3,6 +3,7 @@ package net.thunderbird.feature.mail.message.list.internal.ui import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import net.thunderbird.core.common.resources.StringsResourceManager import net.thunderbird.core.common.state.StateMachine import net.thunderbird.core.logging.Logger import net.thunderbird.feature.mail.message.list.internal.ui.state.machine.MessageListStateMachine @@ -11,35 +12,43 @@ 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 import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory +import net.thunderbird.feature.mail.message.list.R as MessageListApiR private const val TAG = "MessageListViewModel" -class MessageListViewModel( +internal class MessageListViewModel( logger: Logger, messageListStateMachineFactory: MessageListStateMachine.Factory, stateSideEffectHandlersFactories: List, + stringsResourceManager: StringsResourceManager, ) : MessageListContract.ViewModel(logger, stateSideEffectHandlersFactories) { override val stateMachine: StateMachine = messageListStateMachineFactory .create(scope = viewModelScope, dispatch = ::event, dispatchUiEffect = ::emitEffect) - private var previousState: MessageListState? = null init { logger.verbose(TAG) { "init() called" } state .onEach { state -> logger.verbose(TAG) { "state.onEach called with: state = $state" } - val previousState = previousState - if (previousState != null && - previousState.metadata.sortCriteriaPerAccount != state.metadata.sortCriteriaPerAccount - ) { - // 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. - emitEffect(MessageListEffect.RefreshMessageList(state)) - } + when (state) { + is MessageListState.SelectingMessages -> { + val selectedCount = state.messages.count { it.selected } + emitEffect( + if (selectedCount > 0) { + MessageListEffect.UpdateToolbarActionMode( + title = stringsResourceManager.stringResource( + MessageListApiR.string.actionbar_selected, + selectedCount, + ), + isAllSelected = selectedCount == state.messages.size, + ) + } else { + MessageListEffect.ResetToolbarActionMode + }, + ) + } - if (previousState != state) { - this.previousState = state + else -> Unit } } .launchIn(viewModelScope) diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/MessageItemAvatar.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/MessageItemAvatar.kt index eba29d76cdd..19973e14a6e 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/MessageItemAvatar.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/MessageItemAvatar.kt @@ -23,7 +23,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.Avatar @Composable fun MessageItemAvatar( - avatar: Avatar, + avatar: Avatar?, showMessageAvatar: Boolean, modifier: Modifier = Modifier, onAvatarClick: () -> Unit, @@ -60,6 +60,7 @@ fun MessageItemAvatar( ) is Avatar.Monogram -> TextTitleSmall(text = avatar.value) + null -> Unit } } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/template/MessageList.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/template/MessageList.kt index a792d214ab9..e6f5589c486 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/template/MessageList.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/component/template/MessageList.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.lifecycle.compose.LifecycleStartEffect @@ -64,6 +65,14 @@ internal fun MessageListScope.MessageList( .fillMaxWidth() .semantics(mergeDescendants = true) { stateDescription = accessibilityState.stateDescription(message) + } + .focusProperties { + // TODO: Need improvement. Once the `MessageHomeActivity.onCustomKeyDown` is executed + // the focus go back to the toolbar's navigation button. + // We may replace the `MessageHomeActivity.onCustomKeyDown` with a modifier such + // as onPreviewKeyEvent in the future as well. + onEnter = { dispatchEvent(MessageItemEvent.OnFocusEnter(message)) } + onExit = { dispatchEvent(MessageItemEvent.OnFocusExit(message)) } }, onClick = { dispatchEvent(MessageItemEvent.OnMessageClick(message)) }, onLongClick = { dispatchEvent(MessageItemEvent.ToggleSelectMessages(message)) }, 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 01e8a42f411..91c1796a664 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 @@ -59,9 +59,9 @@ class MessageListStateMachine( } warmingUpInitialState(initialState = MessageListState.WarmingUp(), dispatch) globalState() - loadingMessagesState() + loadingMessagesState(dispatch) loadedMessagesState() - selectingMessagesState() + selectingMessagesState(dispatch, dispatchUiEffect) searchingMessagesState() }, ) : StateMachine by 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 index 6f0c0c1c624..823b4f718c6 100644 --- 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 @@ -1,9 +1,12 @@ package net.thunderbird.feature.mail.message.list.internal.ui.state.machine +import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toPersistentMap import net.thunderbird.core.common.state.builder.StateMachineBuilder +import net.thunderbird.feature.account.UnifiedAccountId 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.state.MessageListState @@ -30,7 +33,31 @@ internal fun StateMachineBuilder.globalState } transition { state, (folder) -> - state.withMetadata { copy(folder = folder) } + state.withMetadata { copy(folder = folder, showAccountIndicator = folder.account.id == UnifiedAccountId) } + } + + transition { state, _ -> + MessageListState.SelectingMessages( + metadata = state.metadata, + preferences = requireNotNull(state.preferences), + messages = state.messages.map { it.copy(selected = true) }.toImmutableList(), + ) + } + + transition { state, _ -> + MessageListState.LoadedMessages( + metadata = state.metadata, + preferences = requireNotNull(state.preferences), + messages = state.messages.map { it.copy(selected = false) }.toImmutableList(), + ) + } + + transition { currentState, event -> + currentState.withMetadata { copy(focusedMessage = event.message) } + } + + transition { currentState, _ -> + currentState.withMetadata { copy(focusedMessage = null) } } } } 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 index b2378be5046..058a02ee116 100644 --- 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 @@ -5,7 +5,9 @@ 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.MessageItemUi import net.thunderbird.feature.mail.message.list.ui.state.MessageListState +import net.thunderbird.feature.mail.message.list.ui.state.withState /** * Defines the state transitions for the [MessageListState.LoadedMessages] state. @@ -49,5 +51,26 @@ internal fun StateMachineBuilder.loadedMessa messages = state.messages, ) } + transition { state, event -> + state + .mapMessages { message -> + message.copy(active = message.id == event.message?.id) + } + .withMetadata { copy(activeMessage = event.message) } + } + transition { state, event -> + state + .mapMessages { message -> + val isCurrent = message.id == event.message.id + message.copy(active = isCurrent).withState( + if (isCurrent && message.state != MessageItemUi.State.Read) { + MessageItemUi.State.Read + } else { + message.state + }, + ) + } + .withMetadata { copy(activeMessage = event.message) } + } } } 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 index 50a0c7c14c3..d0151cf4bc3 100644 --- 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 @@ -14,10 +14,15 @@ import net.thunderbird.feature.mail.message.list.ui.state.MessageListState * if the loading progress has reached 100% (`progress == 1f`). This ensures a smooth transition * after the loading animation completes. */ -internal fun StateMachineBuilder.loadingMessagesState() { +internal fun StateMachineBuilder.loadingMessagesState( + dispatch: (MessageListEvent) -> Unit, +) { state { transition { state, event -> - state.copy(progress = event.progress) + if (event.progress == 1f) { + dispatch(MessageListEvent.MessagesLoaded(event.messages)) + } + state.copy(progress = event.progress, messages = event.messages.toPersistentList()) } transition( guard = { state, _ -> state.progress == 1f }, 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 index 6fd7632b1c4..68bdf5f5a92 100644 --- 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 @@ -2,8 +2,10 @@ 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.effect.MessageListEffect 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.MessageItemUi import net.thunderbird.feature.mail.message.list.ui.state.MessageListState /** @@ -16,21 +18,49 @@ import net.thunderbird.feature.mail.message.list.ui.state.MessageListState * - [MessageListEvent.ExitSelectionMode]: Exits selection mode, deselecting all messages and returning * to the [MessageListState.LoadedMessages] state. */ -internal fun StateMachineBuilder.selectingMessagesState() { +internal fun StateMachineBuilder.selectingMessagesState( + dispatch: (MessageListEvent) -> Unit, + dispatchUiEffect: (MessageListEffect) -> Unit, +) { state { transition { state, event -> - state.copy( - messages = state.messages.map { message -> - if (message in event.messages) message.copy(selected = !message.selected) else message - }.toPersistentList(), - ) + toggleSelectMessages(state, event.messages, dispatch, dispatchUiEffect) } + transition { state, _ -> + dispatchUiEffect(MessageListEffect.ResetToolbarActionMode) MessageListState.LoadedMessages( metadata = state.metadata, preferences = state.preferences, messages = state.messages.map { message -> message.copy(selected = false) }.toPersistentList(), ) } + + transition { state, event -> + toggleSelectMessages(state, listOf(event.message), dispatch, dispatchUiEffect) + } + } +} + +private fun toggleSelectMessages( + state: MessageListState.SelectingMessages, + messages: List, + dispatch: (MessageListEvent) -> Unit, + dispatchUiEffect: (MessageListEffect) -> Unit, +): MessageListState.SelectingMessages { + var selectedCount = 0 + val newMessages = state.messages.map { message -> + if (message in messages) { + message.copy(selected = !message.selected) + } else { + message + }.also { selectedCount += if (it.selected) 1 else 0 } + }.toPersistentList() + return if (selectedCount == 0) { + dispatch(MessageListEvent.ExitSelectionMode) + dispatchUiEffect(MessageListEffect.ResetToolbarActionMode) + state + } else { + state.copy(messages = newMessages) } } 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 index 58fd8ab899b..188c254d7d9 100644 --- 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 @@ -15,7 +15,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList * @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( +internal class AllConfigurationsReadySideEffect( logger: Logger, dispatch: suspend (MessageListEvent) -> Unit, ) : MessageListStateSideEffectHandler(logger, dispatch) { diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ChangeSortCriteriaSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ChangeSortCriteriaSideEffect.kt index 90fa062ad37..c487da760f1 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ChangeSortCriteriaSideEffect.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ChangeSortCriteriaSideEffect.kt @@ -11,7 +11,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList private const val TAG = "ChangeSortCriteriaSideEffect" -class ChangeSortCriteriaSideEffect( +internal class ChangeSortCriteriaSideEffect( dispatch: suspend (MessageListEvent) -> Unit, private val logger: Logger, private val updateSortCriteria: DomainContract.UseCase.UpdateSortCriteria, diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffect.kt index a3a50fe448b..693f79447a8 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffect.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffect.kt @@ -3,8 +3,12 @@ package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect import androidx.compose.ui.graphics.Color import app.k9mail.legacy.mailstore.FolderRepository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import net.thunderbird.core.logging.Logger import net.thunderbird.feature.account.AccountId +import net.thunderbird.feature.account.UnifiedAccountId +import net.thunderbird.feature.account.profile.AccountProfileRepository +import net.thunderbird.feature.mail.folder.api.FolderType import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect import net.thunderbird.feature.mail.message.list.ui.event.FolderEvent import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent @@ -16,24 +20,47 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList private const val TAG = "LoadFolderInformationSideEffect" -class LoadFolderInformationSideEffect( +internal class LoadFolderInformationSideEffect( private val accountIds: Set, private val folderId: Long?, dispatch: suspend (MessageListEvent) -> Unit, private val logger: Logger, private val folderRepository: FolderRepository, + private val profileRepository: AccountProfileRepository, ) : MessageListStateSideEffectHandler(logger, dispatch) { override fun accept(event: MessageListEvent, oldState: MessageListState, newState: MessageListState): Boolean = - accountIds.size == 1 && folderId != null && event == MessageListEvent.LoadConfigurations + event == MessageListEvent.LoadConfigurations override suspend fun consume( event: MessageListEvent, oldState: MessageListState, newState: MessageListState, ): ConsumeResult { - val accountId = accountIds.first() - val folderId = requireNotNull(folderId) logger.verbose(TAG) { "$TAG.handle() called with: oldState = $oldState, newState = $newState" } + return if (folderId == null) { + consumeUnifiedFolder() + } else { + consumeSingleAccountFolder(folderId) + } + } + + private suspend fun consumeUnifiedFolder(): ConsumeResult { + dispatch( + FolderEvent.FolderLoaded( + folder = Folder( + id = "unified_inbox", + account = Account(id = UnifiedAccountId, color = Color.Unspecified), + name = "Unified Inbox", + type = FolderType.INBOX, + ), + ), + ) + + return ConsumeResult.Consumed + } + + private suspend fun consumeSingleAccountFolder(folderId: Long): ConsumeResult { + val accountId = accountIds.first() val folder = folderRepository.getFolder(accountId, folderId) return if (folder != null) { val remoteFolder = if (!folder.isLocalOnly) { @@ -42,11 +69,13 @@ class LoadFolderInformationSideEffect( null } + val profile = profileRepository.getById(accountId).first() + val color = profile?.color?.let(::Color) ?: Color.Unspecified dispatch( FolderEvent.FolderLoaded( folder = Folder( id = remoteFolder?.serverId ?: "local_folder", - account = Account(id = accountId, color = Color.Unspecified), // TODO: fetch color + account = Account(id = accountId, color = color), name = folder.name, type = folder.type, ), @@ -63,6 +92,7 @@ class LoadFolderInformationSideEffect( private val folderId: Long?, private val logger: Logger, private val folderRepository: FolderRepository, + private val profileRepository: AccountProfileRepository, ) : MessageListStateSideEffectHandlerFactory { override fun create( scope: CoroutineScope, @@ -74,6 +104,7 @@ class LoadFolderInformationSideEffect( dispatch = dispatch, logger = logger, folderRepository = folderRepository, + profileRepository = profileRepository, ) } } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadPreferencesSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadPreferencesSideEffect.kt index 287864bba15..ac89cb33028 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadPreferencesSideEffect.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadPreferencesSideEffect.kt @@ -15,7 +15,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList private const val TAG = "LoadPreferencesSideEffect" -class LoadPreferencesSideEffect( +internal class LoadPreferencesSideEffect( private val scope: CoroutineScope, dispatch: suspend (MessageListEvent) -> Unit, private val logger: Logger, diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortCriteriaStateSideEffectHandler.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortCriteriaStateSideEffectHandler.kt index 720c8048b5e..cfffde7bc38 100644 --- a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortCriteriaStateSideEffectHandler.kt +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadSortCriteriaStateSideEffectHandler.kt @@ -12,7 +12,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList private const val TAG = "LoadSortCriteriaStateSideEffectHandler" -class LoadSortCriteriaStateSideEffectHandler( +internal class LoadSortCriteriaStateSideEffectHandler( private val accounts: Set, dispatch: suspend (MessageListEvent) -> Unit, private val logger: Logger, 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 index 037282da9d9..8ab92fefe28 100644 --- 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 @@ -15,7 +15,7 @@ import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageList private const val TAG = "LoadSwipeActionsSideEffectHandler" -class LoadSwipeActionsStateSideEffectHandler( +internal class LoadSwipeActionsStateSideEffectHandler( private val scope: CoroutineScope, dispatch: suspend (MessageListEvent) -> Unit, private val 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 index c6e191e4d72..862911c778c 100644 --- 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 @@ -7,6 +7,10 @@ import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.Lo 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.legacy.LoadMessagesLegacySideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui.OpenMessageSideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui.SetMessageActiveSideEffect +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui.ToggleMessageSideEffect import net.thunderbird.feature.mail.message.list.ui.MessageListContract import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory import org.koin.dsl.module @@ -50,8 +54,16 @@ internal val messageListSideEffectsModule = module { folderId = args.folderId, logger = get(), folderRepository = get(), + profileRepository = get(), ) }, { AllConfigurationsReadySideEffect.Factory(logger = get()) }, + { parameters -> + val args = parameters.get() + LoadMessagesLegacySideEffect.Factory(logger = get(), legacyBridge = args.legacyMessageListBridge) + }, + { OpenMessageSideEffect.Factory(logger = get()) }, + { ToggleMessageSideEffect.Factory(logger = get()) }, + { SetMessageActiveSideEffect.Factory(logger = get()) }, ) } diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffect.kt new file mode 100644 index 00000000000..e85e20726e7 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffect.kt @@ -0,0 +1,76 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.legacy + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.catch +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.ui.effect.MessageListEffect +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.legacy.LegacyMessageListBridge +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory + +private const val TAG = "LoadMessagesLegacySideEffect" + +/** + * Side effect handler responsible for loading messages through the legacy message list bridge. + * + * @property scope The coroutine scope in which the message loading operation will be launched + * @property logger Logger instance for tracking loading operations and debugging + * @property legacyBridge Bridge to the legacy message list system for loading messages + * @property dispatch Function to dispatch events back to the state machine + */ +internal class LoadMessagesLegacySideEffect( + private val scope: CoroutineScope, + private val logger: Logger, + private val legacyBridge: LegacyMessageListBridge, + dispatch: suspend (MessageListEvent) -> Unit, +) : MessageListStateSideEffectHandler(logger, dispatch) { + private var running: Job? = null + override fun accept(event: MessageListEvent, oldState: MessageListState, newState: MessageListState): Boolean = + oldState != newState && newState is MessageListState.LoadingMessages + + override suspend fun consume( + event: MessageListEvent, + oldState: MessageListState, + newState: MessageListState, + ): ConsumeResult { + if (newState !is MessageListState.LoadingMessages) return ConsumeResult.Ignored + running?.cancel() + running = legacyBridge + .loadMessages(newState.preferences, newState.metadata) + .onEach { messages -> + logger.verbose(TAG) { "LoadMessagesLegacySideEffect.handle() messages: $messages" } + // this is oversimplified as we currently don't track the loading progress + // in the legacy implementation. + val progress = if (messages.isEmpty()) 0f else 1f + dispatch(MessageListEvent.UpdateLoadingProgress(progress = progress, messages = messages)) + } + .onCompletion { running = null } + .catch { throwable -> + logger.error(TAG) { "LoadMessagesLegacySideEffect failed: $throwable" } + } + .launchIn(scope) + return ConsumeResult.Consumed + } + + class Factory( + private val logger: Logger, + private val legacyBridge: LegacyMessageListBridge, + ) : MessageListStateSideEffectHandlerFactory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, + ): MessageListStateSideEffectHandler = LoadMessagesLegacySideEffect( + scope = scope, + logger = logger, + legacyBridge = legacyBridge, + dispatch = dispatch, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffect.kt new file mode 100644 index 00000000000..e4f90cf1281 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffect.kt @@ -0,0 +1,47 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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 +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory + +internal class OpenMessageSideEffect( + private val logger: Logger, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, +) : MessageListStateSideEffectHandler(logger, dispatch, dispatchUiEffect) { + override fun accept(event: MessageListEvent, oldState: MessageListState, newState: MessageListState): Boolean = + event is MessageItemEvent.OnMessageClick && + oldState is MessageListState.LoadedMessages && + newState is MessageListState.LoadedMessages + + override suspend fun consume( + event: MessageListEvent, + oldState: MessageListState, + newState: MessageListState, + ): ConsumeResult { + val activeMessage = requireNotNull(newState.metadata.activeMessage) { + "onClickMessageSideEffect: activeMessage must not be null" + } + dispatchUiEffect(MessageListEffect.OpenMessage(message = activeMessage)) + return ConsumeResult.Consumed + } + + class Factory( + private val logger: Logger, + ) : MessageListStateSideEffectHandlerFactory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, + ): MessageListStateSideEffectHandler = OpenMessageSideEffect( + logger = logger, + dispatch = dispatch, + dispatchUiEffect = dispatchUiEffect, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffect.kt new file mode 100644 index 00000000000..92ba3efc286 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffect.kt @@ -0,0 +1,47 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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 +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory + +internal class SetMessageActiveSideEffect( + private val logger: Logger, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, +) : MessageListStateSideEffectHandler(logger, dispatch, dispatchUiEffect) { + override fun accept(event: MessageListEvent, oldState: MessageListState, newState: MessageListState): Boolean = + event is MessageItemEvent.SetMessageActive && newState is MessageListState.LoadedMessages + + override suspend fun consume( + event: MessageListEvent, + oldState: MessageListState, + newState: MessageListState, + ): ConsumeResult { + val activeMessage = newState.metadata.activeMessage + return if (activeMessage != null) { + dispatchUiEffect(MessageListEffect.ScrollToMessage(message = activeMessage)) + ConsumeResult.Consumed + } else { + ConsumeResult.Ignored + } + } + + class Factory( + private val logger: Logger, + ) : MessageListStateSideEffectHandlerFactory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, + ): MessageListStateSideEffectHandler = SetMessageActiveSideEffect( + logger = logger, + dispatch = dispatch, + dispatchUiEffect = dispatchUiEffect, + ) + } +} diff --git a/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/ToggleMessageSideEffect.kt b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/ToggleMessageSideEffect.kt new file mode 100644 index 00000000000..61336984019 --- /dev/null +++ b/feature/mail/message/list/internal/src/main/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/ToggleMessageSideEffect.kt @@ -0,0 +1,44 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui + +import kotlinx.coroutines.CoroutineScope +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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 +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandler +import net.thunderbird.feature.mail.message.list.ui.state.sideeffect.MessageListStateSideEffectHandlerFactory + +internal class ToggleMessageSideEffect( + logger: Logger, + dispatch: suspend (MessageListEvent) -> Unit, +) : MessageListStateSideEffectHandler(logger, dispatch) { + + override fun accept(event: MessageListEvent, oldState: MessageListState, newState: MessageListState): Boolean = + event is MessageItemEvent.OnMessageClick && + oldState is MessageListState.SelectingMessages && + newState is MessageListState.SelectingMessages + + override suspend fun consume( + event: MessageListEvent, + oldState: MessageListState, + newState: MessageListState, + ): ConsumeResult { + val event = event as? MessageItemEvent.OnMessageClick ?: return ConsumeResult.Ignored + dispatch(MessageItemEvent.ToggleSelectMessages(event.message)) + return ConsumeResult.Consumed + } + + class Factory( + private val logger: Logger, + ) : MessageListStateSideEffectHandlerFactory { + override fun create( + scope: CoroutineScope, + dispatch: suspend (MessageListEvent) -> Unit, + dispatchUiEffect: suspend (MessageListEffect) -> Unit, + ): MessageListStateSideEffectHandler = ToggleMessageSideEffect( + logger = logger, + dispatch = dispatch, + ) + } +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/BaseMessageListStateMachineTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/BaseMessageListStateMachineTest.kt new file mode 100644 index 00000000000..c2ea1cbd893 --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/BaseMessageListStateMachineTest.kt @@ -0,0 +1,262 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import androidx.compose.ui.graphics.Color +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 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.effect.MessageListEffect +import net.thunderbird.feature.mail.message.list.ui.event.FolderEvent +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 + +open class BaseMessageListStateMachineTest { + protected fun TestScope.createStateMachine( + dispatch: (MessageListEvent) -> Unit = {}, + dispatchUiEffect: (MessageListEffect) -> Unit = {}, + ) = MessageListStateMachine( + logger = TestLogger(), + clock = TestClock(), + scope = this, + dispatch = dispatch, + dispatchUiEffect = dispatchUiEffect, + debuggingSettingsPreferenceManager = FakeDebuggingSettingsPreferenceManager(), + ) + + protected suspend fun TestScope.createStateMachineOnLoadingState( + preferences: MessageListPreferences = createMessageListPreferences(), + sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), + 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 + } + + protected suspend fun TestScope.createStateMachineOnLoadedState( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), + 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)) + advanceUntilIdle() + return stateMachine + } + + protected suspend fun TestScope.createStateMachineOnSearchingMessages( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), + 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)) + stateMachine.process(event = MessageListSearchEvent.EnterSearchMode) + advanceUntilIdle() + return stateMachine + } + + protected suspend fun TestScope.createStateMachineOnSelectingMessages( + messages: List, + preferences: MessageListPreferences = createMessageListPreferences(), + sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), + 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)) + stateMachine.process(event = MessageListEvent.EnterSelectionMode) + advanceUntilIdle() + return stateMachine + } + + protected fun createMessageUiItemList( + size: Int, + accountId: AccountId = AccountIdFactory.create(), + builder: (index: Int) -> MessageItemUi = { index -> + when { + index % 6 == 0 -> createMessageUiItem( + state = State.Unread, + id = "id$index", + accountId = accountId, + ) + + index % 4 == 0 -> createMessageUiItem( + state = State.Read, + id = "id$index", + accountId = accountId, + ) + + index % 2 == 0 -> createMessageUiItem( + state = State.New, + id = "id$index", + accountId = accountId, + ) + + else -> createMessageUiItem( + state = State.Unread, + id = "id$index", + accountId = accountId, + ).copy(active = true) + } + }, + ): List = List(size) { builder(it) } + + protected fun createMessageUiItem( + state: State, + id: String, + messageReference: String = "message_reference", + accountId: AccountId = AccountIdFactory.create(), + senders: ComposedAddressUi = ComposedAddressUi(displayName = "sender"), + subject: String = "mock subject", + excerpt: String = "mock excerpt", + formattedReceivedAt: String = "Jan 2026", + hasAttachments: Boolean = false, + starred: Boolean = false, + encrypted: Boolean = false, + answered: Boolean = false, + forwarded: Boolean = false, + selected: Boolean = false, + threadCount: Int = 0, + ): MessageItemUi = MessageItemUi( + state = state, + id = id, + messageReference = messageReference, + account = Account(id = accountId, color = Color.Unspecified), + senders = senders, + subject = subject, + excerpt = excerpt, + formattedReceivedAt = formattedReceivedAt, + hasAttachments = hasAttachments, + starred = starred, + encrypted = encrypted, + answered = answered, + forwarded = forwarded, + selected = selected, + threadCount = threadCount, + ) + + protected 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, + ) + + protected fun createMessageListPreferences( + density: UiDensity = UiDensity.Default, + groupConversations: Boolean = false, + showCorrespondentNames: Boolean = false, + showMessageAvatar: Boolean = false, + showFavouriteButton: Boolean = false, + senderAboveSubject: Boolean = false, + excerptLines: Int = 1, + dateTimeFormat: MessageListDateTimeFormat = MessageListDateTimeFormat.Contextual, + actionRequiringUserConfirmation: ImmutableSet = persistentSetOf(), + colorizeBackgroundWhenRead: Boolean = false, + ) = MessageListPreferences( + density = density, + groupConversations = groupConversations, + showCorrespondentNames = showCorrespondentNames, + showMessageAvatar = showMessageAvatar, + showFavouriteButton = showFavouriteButton, + senderAboveSubject = senderAboveSubject, + excerptLines = excerptLines, + dateTimeFormat = dateTimeFormat, + actionRequiringUserConfirmation = actionRequiringUserConfirmation, + colorizeBackgroundWhenRead = colorizeBackgroundWhenRead, + ) + + protected class FakeDebuggingSettingsPreferenceManager( + protected 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/machine/GlobalStateTransitionMessageListStateMachineTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/GlobalStateTransitionMessageListStateMachineTest.kt new file mode 100644 index 00000000000..12b24a0cbb3 --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/GlobalStateTransitionMessageListStateMachineTest.kt @@ -0,0 +1,164 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import app.cash.turbine.test +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNull +import assertk.assertions.isTrue +import assertk.assertions.prop +import kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import net.thunderbird.feature.account.AccountIdFactory +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.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +class GlobalStateTransitionMessageListStateMachineTest : BaseMessageListStateMachineTest() { + // region [Global state transitions] + @Test + fun `process() should update focusedMessage when event is OnFocusEnter from LoadedMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val focusMessage = messages.random() + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.OnFocusEnter(focusMessage)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.metadata.focusedMessage } + .isEqualTo(focusMessage) + + expectNoEvents() + } + } + + @Test + fun `process() should clear focusedMessage when event is OnFocusExit from LoadedMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val focusMessage = messages.random() + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Set focus to the random message + stateMachine.process(MessageItemEvent.OnFocusEnter(focusMessage)) + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.metadata.focusedMessage } + .isEqualTo(focusMessage) + + // Act + stateMachine.process(MessageItemEvent.OnFocusExit(focusMessage)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.metadata.focusedMessage } + .isNull() + + expectNoEvents() + } + } + + @Test + fun `process() should move to SelectingMessages with all messages selected when event is SelectAll`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 10) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.SelectAll) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .all { + transform { state -> state.messages.all { it.selected } }.isTrue() + prop(MessageListState.SelectingMessages::selectedCount).isEqualTo(messages.size) + } + + expectNoEvents() + } + } + + @Test + fun `process() should move to LoadedMessages with all messages deselected when event is DeselectAll`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 10) + val stateMachine = createStateMachineOnSelectingMessages(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.DeselectAll) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { state -> state.messages.none { it.selected } } + .isTrue() + + expectNoEvents() + } + } + + @Test + fun `process() should update sortCriteriaPerAccount when event is ChangeSortCriteria`() = + runTest { + // Arrange + val accountId = AccountIdFactory.create() + val newSortCriteria = SortCriteria(SortType.SubjectDesc, SortType.DateDesc) + val messages = createMessageUiItemList(size = 5) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageListEvent.ChangeSortCriteria(accountId, newSortCriteria)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.metadata.sortCriteriaPerAccount[accountId] } + .isEqualTo(newSortCriteria) + + expectNoEvents() + } + } + // endregion [Global state transitions] +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/LoadedMessagesTransitionMessageListStateMachineTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/LoadedMessagesTransitionMessageListStateMachineTest.kt new file mode 100644 index 00000000000..52f7e34a426 --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/machine/LoadedMessagesTransitionMessageListStateMachineTest.kt @@ -0,0 +1,112 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package net.thunderbird.feature.mail.message.list.internal.ui.state.machine + +import app.cash.turbine.test +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 kotlin.test.Test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState + +class LoadedMessagesTransitionMessageListStateMachineTest : BaseMessageListStateMachineTest() { + // region [LoadedMessages - active message tracking] + @Test + fun `process() should set activeMessage when event is OnMessageClick in LoadedMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val clickedMessage = messages.first() + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.OnMessageClick(clickedMessage)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .all { + transform { it.metadata.activeMessage }.isEqualTo(clickedMessage) + transform { state -> state.messages.first { it.id == clickedMessage.id }.active }.isTrue() + } + + expectNoEvents() + } + } + + @Test + fun `process() should set activeMessage when event is SetMessageActive in LoadedMessages`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val targetMessage = messages.first() + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Act + stateMachine.process(MessageItemEvent.SetMessageActive(targetMessage)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .all { + transform { it.metadata.activeMessage }.isEqualTo(targetMessage) + transform { state -> state.messages.first { it.id == targetMessage.id }.active }.isTrue() + } + + expectNoEvents() + } + } + + @Test + fun `process() should clear activeMessage when event is SetMessageActive with null`() = + runTest { + // Arrange + val messages = createMessageUiItemList(size = 5) + val stateMachine = createStateMachineOnLoadedState(messages = messages) + advanceUntilIdle() + + stateMachine.currentState.test { + // enforce correct state before acting. + assertThat(expectMostRecentItem()).isInstanceOf() + + // Set active first + stateMachine.process(MessageItemEvent.SetMessageActive(messages.first())) + assertThat(awaitItem()) + .isInstanceOf() + .transform { it.metadata.activeMessage } + .isNotNull() + + // Act + stateMachine.process(MessageItemEvent.SetMessageActive(null)) + + // Assert + assertThat(awaitItem()) + .isInstanceOf() + .all { + transform { it.metadata.activeMessage }.isNull() + transform { state -> state.messages.none { it.active } }.isTrue() + } + + expectNoEvents() + } + } + // endregion [LoadedMessages - active message tracking] +} 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 ebcd2a41256..0a2d4492b32 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 @@ -1,6 +1,5 @@ package net.thunderbird.feature.mail.message.list.internal.ui.state.machine -import androidx.compose.ui.graphics.Color import app.cash.turbine.test import assertk.all import assertk.assertThat @@ -14,57 +13,28 @@ import dev.mokkery.spy import dev.mokkery.verify import dev.mokkery.verify.VerifyMode 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.effect.MessageListEffect 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 import net.thunderbird.feature.mail.message.list.ui.state.MessageListState @Suppress("MaxLineLength") @OptIn(ExperimentalCoroutinesApi::class) -class MessageListStateMachineTest { - private fun TestScope.createStateMachine( - dispatch: (MessageListEvent) -> Unit = {}, - dispatchUiEffect: (MessageListEffect) -> Unit = {}, - ) = MessageListStateMachine( - logger = TestLogger(), - clock = TestClock(), - scope = this, - dispatch = dispatch, - dispatchUiEffect = dispatchUiEffect, - debuggingSettingsPreferenceManager = FakeDebuggingSettingsPreferenceManager(), - ) +class MessageListStateMachineTest : BaseMessageListStateMachineTest() { // region [WarmingUp state] @Test @@ -572,213 +542,4 @@ class MessageListStateMachineTest { } } // endregion [SearchingMessages state] - - private suspend fun TestScope.createStateMachineOnLoadingState( - preferences: MessageListPreferences = createMessageListPreferences(), - sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), - 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 - } - - private suspend fun TestScope.createStateMachineOnLoadedState( - messages: List, - preferences: MessageListPreferences = createMessageListPreferences(), - sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), - 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)) - advanceUntilIdle() - return stateMachine - } - - private suspend fun TestScope.createStateMachineOnSearchingMessages( - messages: List, - preferences: MessageListPreferences = createMessageListPreferences(), - sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), - 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)) - stateMachine.process(event = MessageListSearchEvent.EnterSearchMode) - advanceUntilIdle() - return stateMachine - } - - private suspend fun TestScope.createStateMachineOnSelectingMessages( - messages: List, - preferences: MessageListPreferences = createMessageListPreferences(), - sortCriteriaPerAccount: Map = mapOf(null to SortCriteria(SortType.DateDesc)), - 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)) - stateMachine.process(event = MessageListEvent.EnterSelectionMode) - advanceUntilIdle() - return stateMachine - } -} - -private fun createMessageListPreferences( - density: UiDensity = UiDensity.Default, - groupConversations: Boolean = false, - showCorrespondentNames: Boolean = false, - showMessageAvatar: Boolean = false, - showFavouriteButton: Boolean = false, - senderAboveSubject: Boolean = false, - excerptLines: Int = 1, - dateTimeFormat: MessageListDateTimeFormat = MessageListDateTimeFormat.Contextual, - actionRequiringUserConfirmation: ImmutableSet = persistentSetOf(), - colorizeBackgroundWhenRead: Boolean = false, -) = MessageListPreferences( - density = density, - groupConversations = groupConversations, - showCorrespondentNames = showCorrespondentNames, - showMessageAvatar = showMessageAvatar, - showFavouriteButton = showFavouriteButton, - senderAboveSubject = senderAboveSubject, - excerptLines = excerptLines, - dateTimeFormat = dateTimeFormat, - actionRequiringUserConfirmation = actionRequiringUserConfirmation, - colorizeBackgroundWhenRead = colorizeBackgroundWhenRead, -) - -private fun createMessageUiItemList( - size: Int, - accountId: AccountId = AccountIdFactory.create(), - builder: (index: Int) -> MessageItemUi = { index -> - when { - index % 6 == 0 -> createMessageUiItem( - state = State.Unread, - id = "id$index", - accountId = accountId, - ) - - index % 4 == 0 -> createMessageUiItem( - state = State.Read, - id = "id$index", - accountId = accountId, - ) - - index % 2 == 0 -> createMessageUiItem( - state = State.New, - id = "id$index", - accountId = accountId, - ) - - else -> createMessageUiItem( - state = State.Unread, - id = "id$index", - accountId = accountId, - ).copy(active = true) - } - }, -): List = List(size) { builder(it) } - -private fun createMessageUiItem( - state: State, - id: String, - accountId: AccountId = AccountIdFactory.create(), - senders: ComposedAddressUi = ComposedAddressUi(displayName = "sender"), - subject: String = "mock subject", - excerpt: String = "mock excerpt", - formattedReceivedAt: String = "Jan 2026", - hasAttachments: Boolean = false, - starred: Boolean = false, - encrypted: Boolean = false, - answered: Boolean = false, - forwarded: Boolean = false, - selected: Boolean = false, - threadCount: Int = 0, -): MessageItemUi = MessageItemUi( - state = state, - id = id, - account = Account(id = accountId, color = Color.Unspecified), - senders = senders, - subject = subject, - excerpt = excerpt, - formattedReceivedAt = formattedReceivedAt, - hasAttachments = hasAttachments, - starred = starred, - encrypted = encrypted, - answered = answered, - forwarded = forwarded, - 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/BaseSideEffectHandlerTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/BaseSideEffectHandlerTest.kt index b9ab24545fd..f88ea432d29 100644 --- a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/BaseSideEffectHandlerTest.kt +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/BaseSideEffectHandlerTest.kt @@ -2,6 +2,7 @@ package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect import androidx.compose.ui.graphics.Color import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentSetOf import net.thunderbird.core.common.action.SwipeAction @@ -16,7 +17,9 @@ 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.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.MessageListMetadata import net.thunderbird.feature.mail.message.list.ui.state.MessageListState @@ -27,6 +30,30 @@ open class BaseSideEffectHandlerTest { preferences = createMessageListPreferences(), ) + protected fun createLoadedMessagesState( + activeMessage: MessageItemUi? = null, + ) = MessageListState.LoadedMessages( + metadata = createReadyMetadata().copy(activeMessage = activeMessage), + preferences = createMessageListPreferences(), + messages = persistentListOf(), + ) + + protected fun createSelectingMessagesState() = MessageListState.SelectingMessages( + metadata = createReadyMetadata(), + preferences = createMessageListPreferences(), + messages = persistentListOf(), + ) + + protected fun createLoadingMessagesState( + progress: Float = 0f, + metadata: MessageListMetadata = createMetadata(), + preferences: MessageListPreferences = createMessageListPreferences(), + ) = MessageListState.LoadingMessages( + progress = progress, + metadata = metadata, + preferences = preferences, + ) + protected fun createReadyMetadata() = MessageListMetadata( folder = Folder( id = "fake", @@ -78,4 +105,23 @@ open class BaseSideEffectHandlerTest { activeMessage = null, isActive = true, ) + + fun createMessageItemUi( + id: String = "1", + ) = MessageItemUi( + state = MessageItemUi.State.Unread, + id = id, + messageReference = "ref-$id", + account = Account(id = AccountIdFactory.create(), color = Color.Unspecified), + senders = ComposedAddressUi(displayName = "sender"), + subject = "subject", + excerpt = "excerpt", + formattedReceivedAt = "Jan 2026", + hasAttachments = false, + starred = false, + encrypted = false, + answered = false, + forwarded = false, + selected = false, + ) } diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffectTest.kt index 6b4c42a3837..c1052ec3427 100644 --- a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffectTest.kt +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/LoadFolderInformationSideEffectTest.kt @@ -1,6 +1,9 @@ +@file:Suppress("MaxLineLength") + package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import app.k9mail.legacy.mailstore.FolderRepository import assertk.assertThat import assertk.assertions.isEqualTo @@ -12,12 +15,22 @@ import dev.mokkery.verify.VerifyMode import dev.mokkery.verifySuspend import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler import net.thunderbird.core.logging.Logger import net.thunderbird.core.logging.testing.TestLogger import net.thunderbird.feature.account.AccountId import net.thunderbird.feature.account.AccountIdFactory +import net.thunderbird.feature.account.UnifiedAccountId +import net.thunderbird.feature.account.avatar.Avatar +import net.thunderbird.feature.account.profile.AccountProfile +import net.thunderbird.feature.account.profile.AccountProfileRepository import net.thunderbird.feature.mail.folder.api.FolderType import net.thunderbird.feature.mail.folder.api.RemoteFolder import net.thunderbird.feature.mail.message.list.internal.fakes.FakeFolderRepository @@ -28,7 +41,6 @@ import net.thunderbird.feature.mail.message.list.ui.state.Folder import net.thunderbird.feature.mail.message.list.ui.state.MessageListState import net.thunderbird.feature.mail.folder.api.Folder as MailFolder -@Suppress("MaxLineLength") class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { @Test fun `handle() should return Consumed when event is LoadConfigurations and folderId is set and accountIds size is one`() = @@ -78,11 +90,11 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { } @Test - fun `handle() should return Ignored when accountIds size is not one`() = runTest { + fun `handle() should return Consumed when folderId is null for unified folder`() = runTest { // Arrange val testSubject = createTestSubject( - accountIds = setOf(AccountIdFactory.create(), AccountIdFactory.create()), - folderId = 1L, + accountIds = setOf(AccountIdFactory.create()), + folderId = null, ) // Act @@ -93,15 +105,65 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { ) // Assert - assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) } @Test - fun `handle() should return Ignored when folderId is null`() = runTest { + fun `handle() should dispatch unified folder when folderId is null`() = runTest { // Arrange + val dispatch = spy Unit>(obj = {}) val testSubject = createTestSubject( accountIds = setOf(AccountIdFactory.create()), folderId = null, + dispatch = dispatch, + ) + + // Act + testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = MessageListState.WarmingUp(), + newState = MessageListState.WarmingUp(), + ) + + // Assert + verifySuspend { + dispatch( + FolderEvent.FolderLoaded( + folder = Folder( + id = "unified_inbox", + account = Account(id = UnifiedAccountId, color = Color.Unspecified), + name = "Unified Inbox", + type = FolderType.INBOX, + ), + ), + ) + } + } + + @Test + fun `handle() should use first accountId when multiple accountIds provided`() = runTest { + // Arrange + val firstAccountId = AccountIdFactory.create() + val secondAccountId = AccountIdFactory.create() + val folderId = 1L + val folder = createMailFolder(id = folderId, name = "Inbox", isLocalOnly = true) + val expectedColor = Color.DarkGray + val dispatch = spy Unit>(obj = {}) + val testSubject = createTestSubject( + accountIds = setOf(firstAccountId, secondAccountId), + folderId = folderId, + dispatch = dispatch, + folderRepository = createFolderRepository( + accountId = firstAccountId, + folderId = folderId, + folder = folder, + ), + profileRepository = FakeAccountProfileRepository( + profiles = listOf( + createAccountProfile(accountId = firstAccountId, color = expectedColor), + createAccountProfile(accountId = secondAccountId), + ), + ), ) // Act @@ -112,7 +174,19 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { ) // Assert - assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) + verifySuspend { + dispatch( + FolderEvent.FolderLoaded( + folder = Folder( + id = "local_folder", + account = Account(id = firstAccountId, color = expectedColor), + name = "Inbox", + type = FolderType.INBOX, + ), + ), + ) + } } @Test @@ -127,11 +201,15 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { folderId = folderId, folder = folder, ) + val expectedColor = Color.Magenta val testSubject = createTestSubject( accountIds = setOf(accountId), folderId = folderId, dispatch = dispatch, folderRepository = folderRepository, + profileRepository = FakeAccountProfileRepository( + profiles = listOf(createAccountProfile(accountId = accountId, expectedColor)), + ), ) // Act @@ -147,7 +225,7 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { FolderEvent.FolderLoaded( folder = Folder( id = "local_folder", - account = Account(id = accountId, color = Color.Unspecified), + account = Account(id = accountId, color = expectedColor), name = "Local", type = FolderType.INBOX, ), @@ -175,11 +253,15 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { folder = folder, remoteFolders = listOf(remoteFolder), ) + val expectedColor = Color.Blue val testSubject = createTestSubject( accountIds = setOf(accountId), folderId = folderId, dispatch = dispatch, folderRepository = folderRepository, + profileRepository = FakeAccountProfileRepository( + profiles = listOf(createAccountProfile(accountId = accountId, expectedColor)), + ), ) // Act @@ -195,7 +277,7 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { FolderEvent.FolderLoaded( folder = Folder( id = "server-id", - account = Account(id = accountId, color = Color.Unspecified), + account = Account(id = accountId, color = expectedColor), name = "Remote", type = FolderType.INBOX, ), @@ -204,6 +286,57 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { } } + @Test + fun `handle() should dispatch FolderLoaded with account color from profile`() = runTest { + // Arrange + val accountId = AccountIdFactory.create() + val folderId = 5L + val folder = createMailFolder(id = folderId, name = "Inbox", isLocalOnly = true) + val expectedColor = 0xFF0000FF.toInt() + val dispatch = spy Unit>(obj = {}) + val testSubject = createTestSubject( + accountIds = setOf(accountId), + folderId = folderId, + dispatch = dispatch, + folderRepository = createFolderRepository( + accountId = accountId, + folderId = folderId, + folder = folder, + ), + profileRepository = FakeAccountProfileRepository( + profiles = listOf( + AccountProfile( + id = accountId, + name = "Test", + color = expectedColor, + avatar = Avatar.Monogram("T"), + ), + ), + ), + ) + + // Act + testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = MessageListState.WarmingUp(), + newState = MessageListState.WarmingUp(), + ) + + // Assert + verifySuspend { + dispatch( + FolderEvent.FolderLoaded( + folder = Folder( + id = "local_folder", + account = Account(id = accountId, color = Color(expectedColor)), + name = "Inbox", + type = FolderType.INBOX, + ), + ), + ) + } + } + @Test fun `handle() should not dispatch when folder is null`() = runTest { // Arrange @@ -274,7 +407,12 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { accountIds = setOf(AccountIdFactory.create()), folderId = 1L, logger = TestLogger(), - folderRepository = mock(), + folderRepository = createFolderRepository( + accountId = AccountIdFactory.create(), + folderId = 1L, + folder = null, + ), + profileRepository = FakeAccountProfileRepository(), ) // Act @@ -293,13 +431,20 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { folderId: Long? = 1L, dispatch: suspend (MessageListEvent) -> Unit = {}, logger: Logger = TestLogger(), - folderRepository: FolderRepository = mock(), + folderRepository: FolderRepository = FakeFolderRepository( + localFolders = emptyMap(), + remoteFolders = emptyMap(), + ), + profileRepository: AccountProfileRepository = FakeAccountProfileRepository( + profiles = accountIds.map(::createAccountProfile), + ), ) = LoadFolderInformationSideEffect( accountIds = accountIds, folderId = folderId, dispatch = dispatch, logger = logger, folderRepository = folderRepository, + profileRepository = profileRepository, ) private fun createFolderRepository( @@ -323,4 +468,28 @@ class LoadFolderInformationSideEffectTest : BaseSideEffectHandlerTest() { type = type, isLocalOnly = isLocalOnly, ) + + private fun createAccountProfile(accountId: AccountId, color: Color = Color.Unspecified): AccountProfile = + AccountProfile( + id = accountId, + name = "Test $accountId", + color = color.toArgb(), + avatar = Avatar.Monogram("T"), + ) + + private class FakeAccountProfileRepository( + profiles: List = emptyList(), + ) : AccountProfileRepository { + private val profiles = flowOf(profiles) + override fun getAll(): Flow> = profiles + + @OptIn(ExperimentalCoroutinesApi::class) + override fun getById(id: AccountId): Flow = profiles + .flatMapConcat { it.asFlow() } + .filter { it.id == id } + + override suspend fun update(accountProfile: AccountProfile) { + error("Not implemented") + } + } } diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ToggleMessageSideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ToggleMessageSideEffectTest.kt new file mode 100644 index 00000000000..a0d7f365e4d --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ToggleMessageSideEffectTest.kt @@ -0,0 +1,122 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import dev.mokkery.spy +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui.ToggleMessageSideEffect +import net.thunderbird.feature.mail.message.list.ui.event.MessageItemEvent +import net.thunderbird.feature.mail.message.list.ui.event.MessageListEvent + +class ToggleMessageSideEffectTest : BaseSideEffectHandlerTest() { + + @Test + fun `handle() should return Consumed when event is OnMessageClick and both states are SelectingMessages`() = + runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createSelectingMessagesState(), + newState = createSelectingMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) + } + + @Test + fun `handle() should return Ignored when event is OnMessageClick but oldState is LoadedMessages`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createLoadedMessagesState(), + newState = createSelectingMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return Ignored when event is OnMessageClick but newState is LoadedMessages`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createSelectingMessagesState(), + newState = createLoadedMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return Ignored when event is not OnMessageClick`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageListEvent.EnterSelectionMode, + oldState = createSelectingMessagesState(), + newState = createSelectingMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should dispatch ToggleSelectMessages with clicked message`() = runTest { + // Arrange + val message = createMessageItemUi() + val dispatch = spy Unit>(obj = {}) + val testSubject = createTestSubject(dispatch = dispatch) + + // Act + testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createSelectingMessagesState(), + newState = createSelectingMessagesState(), + ) + + // Assert + verifySuspend { dispatch(MessageItemEvent.ToggleSelectMessages(message)) } + } + + @Test + fun `Factory should create a ToggleMessageSideEffect instance`() = runTest { + // Arrange + val factory = ToggleMessageSideEffect.Factory(logger = TestLogger()) + + // Act + val result = factory.create(scope = this, dispatch = {}, dispatchUiEffect = {}) + + // Assert + assertThat(result).isInstanceOf(ToggleMessageSideEffect::class) + } + + private fun createTestSubject( + dispatch: suspend (MessageListEvent) -> Unit = {}, + ) = ToggleMessageSideEffect( + logger = TestLogger(), + dispatch = dispatch, + ) +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffectTest.kt new file mode 100644 index 00000000000..c91bb7dea7b --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/legacy/LoadMessagesLegacySideEffectTest.kt @@ -0,0 +1,355 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.legacy + +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.prop +import dev.mokkery.spy +import dev.mokkery.verify.VerifyMode +import dev.mokkery.verifySuspend +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.LogLevel +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.BaseSideEffectHandlerTest +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.legacy.LegacyMessageListBridge +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi +import net.thunderbird.feature.mail.message.list.ui.state.MessageListMetadata +import net.thunderbird.feature.mail.message.list.ui.state.MessageListState +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LoadMessagesLegacySideEffectTest : BaseSideEffectHandlerTest() { + + @Test + fun `handle() should return Consumed when newState is LoadingMessages`() = runTest { + // Arrange + val testSubject = createTestSubject(scope = backgroundScope) + + // Act + val actual = testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = MessageListState.WarmingUp(), + newState = createLoadingMessagesState(), + ) + + // Assert + assertThat(actual) + .isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) + } + + @Test + fun `handle() should return ignored when newState is WarmingUp`() = runTest { + // Arrange + val testSubject = createTestSubject(scope = backgroundScope) + + // Act + val actual = testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = MessageListState.WarmingUp(), + newState = createReadyWarmingUpState(), + ) + + // Assert + assertThat(actual) + .isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return ignored when newState is LoadedMessages`() = runTest { + // Arrange + val testSubject = createTestSubject(scope = backgroundScope) + + // Act + val actual = testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = MessageListState.WarmingUp(), + newState = MessageListState.LoadedMessages( + metadata = createMetadata(), + preferences = createMessageListPreferences(), + messages = persistentListOf(), + ), + ) + + // Assert + assertThat(actual) + .isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should dispatch UpdateLoadingProgress with progress 1f for non-empty messages`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatch = spy Unit>(obj = {}) + val fakeMessages = listOf(createMessageItemUi()) + val fakeBridge = FakeLegacyMessageListBridge(flow { emit(fakeMessages) }) + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = dispatch, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) + + // Assert + verifySuspend(mode = VerifyMode.exactly(1)) { + dispatch(MessageListEvent.UpdateLoadingProgress(progress = 1f, messages = fakeMessages)) + } + } + + @Test + fun `handle() should dispatch UpdateLoadingProgress with progress 0f for empty messages`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatch = spy Unit>(obj = {}) + val fakeBridge = FakeLegacyMessageListBridge(flow { emit(emptyList()) }) + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = dispatch, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) + + // Assert + verifySuspend(mode = VerifyMode.exactly(1)) { + dispatch(MessageListEvent.UpdateLoadingProgress(progress = 0f, messages = emptyList())) + } + } + + @Test + fun `handle() should do nothing when newState is not LoadingMessages`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatch = spy Unit>(obj = {}) + val fakeBridge = FakeLegacyMessageListBridge() + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = dispatch, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = MessageListState.WarmingUp() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) + + // Assert + verifySuspend(mode = VerifyMode.exactly(0)) { + dispatch(MessageListEvent.UpdateLoadingProgress(progress = 0f)) + } + assertThat(fakeBridge.loadMessagesCallCount).isEqualTo(0) + } + + @Test + fun `handle() should cancel previous running job when called again`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatchedEvents = mutableListOf() + val firstFlow = MutableSharedFlow>() + val secondMessages = listOf(createMessageItemUi(id = "second")) + val secondFlow = flow { emit(secondMessages) } + var callCount = 0 + val fakeBridge = FakeLegacyMessageListBridge( + flowProvider = { + callCount++ + if (callCount == 1) firstFlow else secondFlow + }, + ) + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = { dispatchedEvents.add(it) }, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle( + event = MessageListEvent.LoadNextPage, + oldState, + newState, + ) // first call - flow stays open + testSubject.handle( + event = MessageListEvent.LoadNextPage, + oldState, + newState, + ) // second call - cancels first, completes immediately + + // Try to emit on the first flow (should be cancelled) + firstFlow.emit(listOf(createMessageItemUi(id = "first"))) + advanceUntilIdle() + + // Assert - only the second flow's emission should have been dispatched + assertThat(dispatchedEvents).hasSize(1) + assertThat(dispatchedEvents[0]).isEqualTo( + MessageListEvent.UpdateLoadingProgress(progress = 1f, messages = secondMessages), + ) + } + + @Test + fun `handle() should log error when flow throws exception`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val testLogger = TestLogger() + val exception = RuntimeException("test error") + val fakeBridge = FakeLegacyMessageListBridge( + flow { throw exception }, + ) + val testSubject = createTestSubject( + scope = backgroundScope, + logger = testLogger, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) + + // Assert + val errorEvents = testLogger.events.filter { it.level == LogLevel.ERROR } + assertThat(errorEvents).hasSize(1) + assertThat(errorEvents[0]).prop("tag") { it.tag } + .isEqualTo("LoadMessagesLegacySideEffect") + } + + @Test + fun `handle() should dispatch multiple updates when flow emits multiple times`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatchedEvents = mutableListOf() + val sharedFlow = MutableSharedFlow>() + val fakeBridge = FakeLegacyMessageListBridge(sharedFlow) + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = { dispatchedEvents.add(it) }, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) + sharedFlow.emit(emptyList()) + sharedFlow.emit(listOf(createMessageItemUi())) + sharedFlow.emit(listOf(createMessageItemUi(), createMessageItemUi(id = "2"))) + + // Assert + assertThat(dispatchedEvents).hasSize(3) + assertThat(dispatchedEvents[0]) + .isEqualTo(MessageListEvent.UpdateLoadingProgress(progress = 0f, messages = emptyList())) + assertThat(dispatchedEvents[1].let { it as MessageListEvent.UpdateLoadingProgress }.progress) + .isEqualTo(1f) + assertThat(dispatchedEvents[2].let { it as MessageListEvent.UpdateLoadingProgress }.progress) + .isEqualTo(1f) + } + + @Test + fun `handle() should work correctly after previous flow completes`() = + runTest(UnconfinedTestDispatcher()) { + // Arrange + val dispatchedEvents = mutableListOf() + val firstMessages = listOf(createMessageItemUi(id = "first")) + val secondMessages = listOf(createMessageItemUi(id = "second")) + var callCount = 0 + val fakeBridge = FakeLegacyMessageListBridge( + flowProvider = { + callCount++ + if (callCount == 1) flow { emit(firstMessages) } else flow { emit(secondMessages) } + }, + ) + val testSubject = createTestSubject( + scope = backgroundScope, + dispatch = { dispatchedEvents.add(it) }, + legacyBridge = fakeBridge, + ) + val oldState = MessageListState.WarmingUp() + val newState = createLoadingMessagesState() + + // Act + testSubject.handle(event = MessageListEvent.LoadNextPage, oldState, newState) // first call - completes + advanceUntilIdle() + testSubject.handle( + event = MessageListEvent.LoadNextPage, + oldState, + newState, + ) // second call - should work fine + advanceUntilIdle() + + // Assert + assertThat(dispatchedEvents).hasSize(2) + assertThat(dispatchedEvents[0]).isEqualTo( + MessageListEvent.UpdateLoadingProgress(progress = 1f, messages = firstMessages), + ) + assertThat(dispatchedEvents[1]).isEqualTo( + MessageListEvent.UpdateLoadingProgress(progress = 1f, messages = secondMessages), + ) + } + + @Test + fun `Factory should create a LoadMessagesLegacySideEffect instance`() = runTest { + // Arrange + val factory = LoadMessagesLegacySideEffect.Factory( + logger = TestLogger(), + legacyBridge = FakeLegacyMessageListBridge(), + ) + + // Act + val result = factory.create(scope = backgroundScope, dispatch = {}, dispatchUiEffect = {}) + + // Assert + assertThat(result) + .isInstanceOf>() + } + + private fun createTestSubject( + scope: kotlinx.coroutines.CoroutineScope, + logger: TestLogger = TestLogger(), + legacyBridge: LegacyMessageListBridge = FakeLegacyMessageListBridge(), + dispatch: suspend (MessageListEvent) -> Unit = {}, + ) = LoadMessagesLegacySideEffect( + scope = scope, + logger = logger, + legacyBridge = legacyBridge, + dispatch = dispatch, + ) +} + +private class FakeLegacyMessageListBridge( + private val flowToReturn: Flow> = MutableSharedFlow(), +) : LegacyMessageListBridge { + var loadMessagesCallCount = 0 + private set + + constructor(flowProvider: () -> Flow>) : this() { + this.flowProvider = flowProvider + } + + private var flowProvider: (() -> Flow>)? = null + + override fun loadMessages( + preferences: MessageListPreferences, + metadata: MessageListMetadata, + ): Flow> { + loadMessagesCallCount++ + return flowProvider?.invoke() ?: flowToReturn + } +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffectTest.kt new file mode 100644 index 00000000000..1eaa4cd00a7 --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/OpenMessageSideEffectTest.kt @@ -0,0 +1,130 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import dev.mokkery.spy +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.BaseSideEffectHandlerTest +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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 + +class OpenMessageSideEffectTest : BaseSideEffectHandlerTest() { + + @Test + fun `handle() should return Consumed when event is OnMessageClick and newState is LoadedMessages`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = message), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) + } + + @Test + fun `handle() should return Ignored when event is OnMessageClick but newState is SelectingMessages`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createLoadedMessagesState(), + newState = MessageListState.SelectingMessages( + metadata = createReadyMetadata(), + preferences = createMessageListPreferences(), + messages = persistentListOf(), + ), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return Ignored when event is not OnMessageClick`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageListEvent.EnterSelectionMode, + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should dispatch OpenMessage effect with active message`() = runTest { + // Arrange + val message = createMessageItemUi() + val dispatchUiEffect = spy Unit>(obj = {}) + val testSubject = createTestSubject(dispatchUiEffect = dispatchUiEffect) + + // Act + testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = message), + ) + + // Assert + verifySuspend { dispatchUiEffect(MessageListEffect.OpenMessage(message = message)) } + } + + @Test + fun `handle() should throw when activeMessage is null`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act & Assert + assertFailsWith { + testSubject.handle( + event = MessageItemEvent.OnMessageClick(message), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = null), + ) + } + } + + @Test + fun `Factory should create an OpenMessageSideEffect instance`() = runTest { + // Arrange + val factory = OpenMessageSideEffect.Factory(logger = TestLogger()) + + // Act + val result = factory.create(scope = this, dispatch = {}, dispatchUiEffect = {}) + + // Assert + assertThat(result).isInstanceOf(OpenMessageSideEffect::class) + } + + private fun createTestSubject( + dispatch: suspend (MessageListEvent) -> Unit = {}, + dispatchUiEffect: suspend (MessageListEffect) -> Unit = {}, + ) = OpenMessageSideEffect( + logger = TestLogger(), + dispatch = dispatch, + dispatchUiEffect = dispatchUiEffect, + ) +} diff --git a/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffectTest.kt b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffectTest.kt new file mode 100644 index 00000000000..1b9a1e720db --- /dev/null +++ b/feature/mail/message/list/internal/src/test/kotlin/net/thunderbird/feature/mail/message/list/internal/ui/state/sideeffect/ui/SetMessageActiveSideEffectTest.kt @@ -0,0 +1,127 @@ +package net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.ui + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import dev.mokkery.spy +import dev.mokkery.verifySuspend +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler +import net.thunderbird.core.logging.testing.TestLogger +import net.thunderbird.feature.mail.message.list.internal.ui.state.sideeffect.BaseSideEffectHandlerTest +import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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 + +@Suppress("MaxLineLength") +class SetMessageActiveSideEffectTest : BaseSideEffectHandlerTest() { + + @Test + fun `handle() should return Consumed when event is SetMessageActive, newState is LoadedMessages, and activeMessage is not null`() = + runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.SetMessageActive(message), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = message), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Consumed) + } + + @Test + fun `handle() should return Ignored when event is SetMessageActive, newState is LoadedMessages, but activeMessage is null`() = + runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.SetMessageActive(null), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = null), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return Ignored when event is SetMessageActive but newState is not LoadedMessages`() = runTest { + // Arrange + val message = createMessageItemUi() + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageItemEvent.SetMessageActive(message), + oldState = MessageListState.WarmingUp(), + newState = MessageListState.WarmingUp(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should return Ignored when event is not SetMessageActive`() = runTest { + // Arrange + val testSubject = createTestSubject() + + // Act + val result = testSubject.handle( + event = MessageListEvent.LoadConfigurations, + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(), + ) + + // Assert + assertThat(result).isEqualTo(StateSideEffectHandler.ConsumeResult.Ignored) + } + + @Test + fun `handle() should dispatch ScrollToMessage effect when activeMessage is not null`() = runTest { + // Arrange + val message = createMessageItemUi() + val dispatchUiEffect = spy Unit>(obj = {}) + val testSubject = createTestSubject(dispatchUiEffect = dispatchUiEffect) + + // Act + testSubject.handle( + event = MessageItemEvent.SetMessageActive(message), + oldState = createLoadedMessagesState(), + newState = createLoadedMessagesState(activeMessage = message), + ) + + // Assert + verifySuspend { dispatchUiEffect(MessageListEffect.ScrollToMessage(message = message)) } + } + + @Test + fun `Factory should create a SetMessageActiveSideEffect instance`() = runTest { + // Arrange + val factory = SetMessageActiveSideEffect.Factory(logger = TestLogger()) + + // Act + val result = factory.create(scope = this, dispatch = {}, dispatchUiEffect = {}) + + // Assert + assertThat(result).isInstanceOf(SetMessageActiveSideEffect::class) + } + + private fun createTestSubject( + dispatch: suspend (MessageListEvent) -> Unit = {}, + dispatchUiEffect: suspend (MessageListEffect) -> Unit = {}, + ) = SetMessageActiveSideEffect( + logger = TestLogger(), + dispatch = dispatch, + dispatchUiEffect = dispatchUiEffect, + ) +} 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 62f4feff293..79a6cacba54 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 @@ -61,15 +61,15 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.Observer +import androidx.lifecycle.asFlow import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +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 @@ -109,6 +109,7 @@ 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.toMessageItemUi import com.google.android.material.button.MaterialButton import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton @@ -119,6 +120,7 @@ import java.util.concurrent.Future import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn @@ -145,14 +147,19 @@ import net.thunderbird.core.ui.contract.mvi.observeWithoutEffect import net.thunderbird.core.ui.theme.api.FeatureThemeProvider import net.thunderbird.feature.account.AccountIdFactory import net.thunderbird.feature.account.UnifiedAccountId +import net.thunderbird.feature.account.avatar.AvatarMonogramCreator 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.component.MessageListScope import net.thunderbird.feature.mail.message.list.ui.effect.MessageListEffect +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.legacy.LegacyMessageListBridge +import net.thunderbird.feature.mail.message.list.ui.state.MessageItemUi 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 @@ -169,7 +176,6 @@ import org.koin.core.parameter.parametersOf import app.k9mail.core.ui.legacy.designsystem.R as DesignSystemR import com.google.android.material.R as MaterialR import net.thunderbird.core.android.account.SortType as LegacySortType -import net.thunderbird.feature.mail.message.list.R as MessageListApiR private const val TAG = "MessageListFragment" @@ -189,6 +195,7 @@ private const val TAG = "MessageListFragment" class MessageListFragment : Fragment(), MessageListFragmentBridgeContract, + LegacyMessageListBridge, ConfirmationDialogFragmentListener, MessageListItemActionListener, ErrorNotificationsDialogFragmentActionListener { @@ -220,6 +227,8 @@ class MessageListFragment : 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 -> @@ -421,36 +430,6 @@ class MessageListFragment : return null } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - return if (error == null) { - 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 -> - 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?) { legacyViewModel.getMessageListLiveData().observe(viewLifecycleOwner) { messageListInfo: MessageListInfo -> setMessageList(messageListInfo) @@ -486,34 +465,6 @@ class MessageListFragment : } else { initializeErrorLayout(view) } - - lifecycleScope.launch { - viewLifecycleOwner.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 -> { - val (primarySortType, secondarySortType) = effect.currentState.metadata.currentSortCriteria - val (sortType, sortAscending) = primarySortType.toDomainSortType() - updateCurrentSortCriteria( - sortType = sortType, - sortAscending = sortAscending, - sortDateAscending = when (primarySortType) { - SortType.DateAsc -> true - SortType.DateDesc -> false - else -> secondarySortType == SortType.DateAsc - }, - ) - loadMessageList() - } - - else -> Unit - } - } - } - } } private fun initializeErrorLayout(view: View) { @@ -677,8 +628,6 @@ class MessageListFragment : if (forceUpdate) { accounts = config.search.getLegacyAccounts(accountManager) } - - legacyViewModel.loadMessageList(config, forceUpdate) } override fun folderLoading(folderId: Long, loading: Boolean) { @@ -815,8 +764,6 @@ class MessageListFragment : if (error != null) return - // 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()) @@ -1360,46 +1307,24 @@ class MessageListFragment : } private fun selectAll() { - if (viewModel.state.value.messages.isEmpty()) { + if (stateSnapshot.messages.isEmpty()) { // Nothing to do if there are no messages return } - // TODO(#10775): trigger select all event here. - - if (actionMode == null) { - startAndPrepareActionMode() - } - - computeBatchDirection() - updateActionMode() - } - - // 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 false + viewModel.event(MessageItemEvent.SelectAll) } - private fun updateAfterSelectionChange() { - if (selectedMessagesCount == 0) { - actionMode?.finish() - actionMode = null - return - } - - if (actionMode == null) { - startAndPrepareActionMode() - } - - computeBatchDirection() - updateActionMode() + private fun toggleMessageSelect(messageListItem: MessageListItem) { + val preferences = stateSnapshot.preferences + val messageItem = messageListItem.toMessageItemUi( + showContactPicture = preferences?.showMessageAvatar == true, + isSelected = messageListItem.messageReference in selectedMessages, + isActive = messageListItem.messageReference == activeMessage, + monogram = "", + url = null, + ) + viewModel.event(MessageItemEvent.ToggleSelectMessages(messageItem)) } override fun onToggleMessageSelection(item: MessageListItem) { @@ -1407,39 +1332,27 @@ class MessageListFragment : } override fun onToggleMessageFlag(item: MessageListItem) { - setFlag(item, Flag.FLAGGED, !item.isStarred) - } - - private fun updateActionMode() { - val actionMode = actionMode ?: error("actionMode == null") - val isAllSelected = stateSnapshot.messages.size == selectedMessagesCount - actionMode.title = getString(MessageListApiR.string.actionbar_selected, selectedMessagesCount) - actionModeCallback.showSelectAll(!isAllSelected) - - actionMode.invalidate() - } - - private fun computeBatchDirection() { - // 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) + val preferences = stateSnapshot.preferences + val messageItem = item.toMessageItemUi( + showContactPicture = preferences?.showMessageAvatar == true, + isSelected = item.messageReference in selectedMessages, + isActive = item.messageReference == activeMessage, + monogram = "", + url = null, + ) + setFlag(messageItem, Flag.FLAGGED, !item.isStarred) } - 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) + private fun setFlag(messageItemUi: MessageItemUi, flag: Flag, newState: Boolean) { + val account = messageItemUi.account + if (showingThreadedList && messageItemUi.threadCount > 1) { + // TODO +// val threadRootId = messageItemUi.threadRoot +// messagingController.setFlagForThreads(account.id, listOf(threadRootId), flag, newState) } else { - val messageId = messageListItem.databaseId + val messageId = messageItemUi.id.toLong() messagingController.setFlag(account.id, listOf(messageId), flag, newState) } - - computeBatchDirection() } private fun setFlagForSelected(flag: Flag, newState: Boolean) { @@ -1471,8 +1384,6 @@ class MessageListFragment : messagingController.setFlagForThreads(account.id, threadRootIds, flag, newState) } } - - computeBatchDirection() } private fun onMove(message: MessageReference) { @@ -1799,57 +1710,57 @@ class MessageListFragment : changeSort(sortType) } - private val selectedMessage: MessageReference? - get() = selectedMessageListItem?.messageReference + private val focusedMessageReference: MessageReference? + get() = MessageReference.parse(focusedMessage?.messageReference) - private val selectedMessageListItem: MessageListItem? - get() { - // TODO(#10775): return adapter.getItemById(viewHolder.uniqueId) - return null - } + private val focusedMessage: MessageItemUi? + get() = stateSnapshot.metadata.focusedMessage private val selectedMessages: List - // TODO(#10775): get() = adapter.selectedMessages.map { it.messageReference } - get() = emptyList() + get() = viewModel.state + .value + .messages + .filter { it.selected } + .mapNotNull { MessageReference.parse(it.messageReference) } override fun onDelete() { - selectedMessage?.let { message -> + focusedMessageReference?.let { message -> onDelete(listOf(message)) } } override fun toggleMessageSelect() { - selectedMessageListItem?.let { messageListItem -> - toggleMessageSelect(messageListItem) + focusedMessage?.let { messageItem -> + viewModel.event(MessageItemEvent.ToggleSelectMessages(messageItem)) } } override fun onToggleFlagged() { - selectedMessageListItem?.let { messageListItem -> - setFlag(messageListItem, Flag.FLAGGED, !messageListItem.isStarred) + focusedMessage?.let { messageListItem -> + setFlag(messageListItem, Flag.FLAGGED, !messageListItem.starred) } } override fun onToggleRead() { - selectedMessageListItem?.let { messageListItem -> - setFlag(messageListItem, Flag.SEEN, !messageListItem.isRead) + focusedMessage?.let { messageListItem -> + setFlag(messageListItem, Flag.SEEN, !messageListItem.starred) } } override fun onMove() { - selectedMessage?.let { message -> + focusedMessageReference?.let { message -> onMove(message) } } override fun onArchive() { - selectedMessage?.let { message -> + focusedMessageReference?.let { message -> onArchive(message) } } override fun onCopy() { - selectedMessage?.let { message -> + focusedMessageReference?.let { message -> onCopy(message) } } @@ -1941,8 +1852,6 @@ class MessageListFragment : .forEach { account -> messagingController.checkAuthenticationProblem(account.id) } resetActionMode() - computeBatchDirection() - invalidateMenu() initialMessageListLoad = false @@ -1965,8 +1874,6 @@ class MessageListFragment : if (actionMode == null) { startAndPrepareActionMode() } - - updateActionMode() } private fun startAndPrepareActionMode() { @@ -1983,24 +1890,11 @@ class MessageListFragment : } override fun setActiveMessage(messageReference: MessageReference?) { + // TODO: Move the activeMessage reference somehow to viewmodel or state. activeMessage = messageReference - - rememberSortOverride(messageReference) - - // Reload message list with modified query that always includes the active message - if (isAdded) { - loadMessageList() - } - - // Redraw list immediately - // TODO(#10775): Verify if the below code is still required. -// if (::adapter.isInitialized) { -// adapter.activeMessage = activeMessage -// -// if (messageReference != null) { -// scrollToMessage(messageReference) -// } -// } + val message = stateSnapshot.messages + .find { it.messageReference == messageReference?.toIdentityString() } + viewModel.event(MessageItemEvent.SetMessageActive(message)) } override fun onFullyActive() { @@ -2015,38 +1909,6 @@ class MessageListFragment : 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. - // 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 - - if (messageReference == null) { - messageSortOverrides.clear() - return - } - - if (sortType != LegacySortType.SORT_UNREAD && sortType != LegacySortType.SORT_FLAGGED) return - - // 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 get() = isSingleAccountMode && isSingleFolderMode && !isOutbox @@ -2290,7 +2152,7 @@ class MessageListFragment : } private val accountUuidsForSelected: Set - get() = viewModel.state.value.messages.filter { it.selected }.mapToSet { it.account.id.toString() } + get() = stateSnapshot.messages.filter { it.selected }.mapToSet { it.account.id.toString() } override fun onDestroyActionMode(mode: ActionMode) { actionMode = null @@ -2300,7 +2162,7 @@ class MessageListFragment : flag = null unflag = null - // TODO(#10775): clear the current selected messages here, if needed. + viewModel.event(MessageItemEvent.DeselectAll) } override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { @@ -2462,13 +2324,14 @@ class MessageListFragment : } // endregion [ LegacyMessageListFragment methods] - private val viewModel: MessageListContract.ViewModel by inject { + private val viewModel: MessageListContract.ViewModel by viewModel { decodeArguments() val accounts = accountUuids.map { AccountIdFactory.of(it) }.toSet() var args = MessageListContract.ViewModel.Args( accountIds = accounts, folderId = if (accounts.size == 1) currentFolder?.databaseId else null, + legacyMessageListBridge = this, ) parametersOf(args) } @@ -2476,12 +2339,123 @@ class MessageListFragment : val stateSnapshot get() = viewModel.state.value val selectedMessagesCount - get() = (viewModel.state.value as? MessageListState.SelectingMessages)?.selectedCount.orZero() + get() = (stateSnapshot as? MessageListState.SelectingMessages)?.selectedCount.orZero() internal val MessageListMetadata.currentSortCriteria: SortCriteria get() = sortCriteriaPerAccount.getValue(folder?.account?.id?.takeIf { it != UnifiedAccountId }) + // region [ Legacy Message List Bridge methods ] + override fun loadMessages( + preferences: MessageListPreferences, + metadata: MessageListMetadata, + ): Flow> { + val (primarySortType, _) = metadata.currentSortCriteria + val (sortType, sortAscending) = primarySortType.toDomainSortType() + val config = MessageListConfig( + search = localSearch, + showingThreadedList = showingThreadedList, + sortType = sortType, + sortAscending = sortAscending, + sortDateAscending = sortDateAscending, + activeMessage = activeMessage, + sortOverrides = legacyViewModel.messageSortOverrides.toMap(), + ) + legacyViewModel.loadMessageList(config) + return legacyViewModel + .getMessageListLiveData() + .asFlow() + .map { info -> + info.messageListItems.map { item -> + val url = contactRepository.getPhotoUri( + item.displayAddress?.address ?: "", + ) + val monogram = avatarMonogramCreator.create( + item.displayName.toString(), + item.displayAddress?.address, + ) + item.toMessageItemUi( + showContactPicture = preferences.showMessageAvatar, + isSelected = false, + isActive = item.messageReference == activeMessage, + monogram = monogram, + url = url?.toString(), + ) + } + } + } + + // endregion [ Legacy Message List Bridge methods ] + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return if (error == null) { + 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 = { handleMessageListEffect(it) }, + inAppNotificationEventFilter = ::filterInAppNotificationEvents, + viewModel = viewModel, + ) + } + } + } + } + } else { + inflater.inflate(R.layout.message_list_error, container, false) + } + } + + private fun MessageListScope.handleMessageListEffect(effect: MessageListEffect) { + when (effect) { + is MessageListEffect.ScrollToMessage -> scrollToMessage(effect.message) + is MessageListEffect.UpdateToolbarActionMode -> { + println( + "[MessageList] UpdateToolbarActionMode called with title: ${effect.title}, state = $stateSnapshot", + ) + if (actionMode == null) { + startAndPrepareActionMode() + } + actionMode?.let { actionMode -> + actionMode.title = effect.title + actionModeCallback.showSelectAll(!effect.isAllSelected) + actionMode.invalidate() + } + } + + is MessageListEffect.ResetToolbarActionMode -> { + println("[MessageList] ResetToolbarActionMode called with state = $stateSnapshot") + resetActionMode() + } + + is MessageListEffect.RefreshMessageList -> { + val (primarySortType, secondarySortType) = effect.currentState.metadata.currentSortCriteria + val (sortType, sortAscending) = primarySortType.toDomainSortType() + updateCurrentSortCriteria( + sortType = sortType, + sortAscending = sortAscending, + sortDateAscending = when (primarySortType) { + SortType.DateAsc -> true + SortType.DateDesc -> false + else -> secondarySortType == SortType.DateAsc + }, + ) + loadMessageList() + } + + is MessageListEffect.OpenMessage -> { + val messageReference = checkNotNull(MessageReference.parse(effect.message.messageReference)) { + "The message reference should not be null when opening a message. Message: $effect" + } + openMessage(messageReference) + } + + else -> Unit + } + } + private fun showComposeDropdown(anchor: View, lifecycleOwner: LifecycleOwner, stateOwner: SavedStateRegistryOwner) { val context = anchor.context val composeView = ComposeView(context).apply { @@ -2522,11 +2496,11 @@ class MessageListFragment : private fun SortCriteriaMenuList() { val (state, dispatch) = viewModel.observeWithoutEffect() val metadata = state.value.metadata + val folder = metadata.folder + val accountId = folder?.account?.id?.takeIf { it != UnifiedAccountId } val primarySortTypes = metadata.availablePrimarySortTypes val secondarySortTypes = metadata.availableSecondarySortTypes val currentSortCriteria = remember(metadata) { - val folder = metadata.folder - val accountId = folder?.account?.id metadata.sortCriteriaPerAccount.getValue(accountId) } @@ -2544,7 +2518,7 @@ class MessageListFragment : onSortTypeClick = { sortType -> dispatch( MessageListEvent.ChangeSortCriteria( - accountId = null, + accountId = accountId, sortCriteria = currentSortCriteria.copy( primary = sortType, secondary = when { @@ -2558,7 +2532,7 @@ class MessageListFragment : onSecondarySortTypeClick = { sortType -> dispatch( MessageListEvent.ChangeSortCriteria( - accountId = null, + accountId = accountId, sortCriteria = currentSortCriteria.copy(secondary = sortType), ), ) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt index 34ac8236872..1808f76ae1f 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListLoader.kt @@ -72,7 +72,8 @@ class MessageListLoader( ) }, contactLetterBitmapCreator = contactLetterBitmapCreator.takeIf { - featureFlagProvider.provide(MessageListFeatureFlags.UseComposeForMessageListItems).isEnabled() + featureFlagProvider.provide(MessageListFeatureFlags.UseComposeForMessageListItems).isEnabled() || + featureFlagProvider.provide(MessageListFeatureFlags.EnableMessageListNewState).isEnabled() }, ) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt index abe1f2b0293..f3f8117f3e2 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageItemContent.kt @@ -110,38 +110,47 @@ private fun rememberMessageItemUi( monogram: String, url: String?, ): MessageItemUi = remember(item, showContactPicture, isSelected, isActive, monogram, url) { - MessageItemUi( - state = if (item.isRead) MessageItemUi.State.Read else MessageItemUi.State.Unread, - id = item.messageUid, - account = Account( - id = item.account.id, - color = Color(item.account.profile.color), - ), - senders = ComposedAddressUi( - displayName = item.displayAddress?.address ?: "", - displayNameStyles = item.buildSenderStyles(), - avatar = when { - !showContactPicture -> null - showContactPicture && url != null -> Avatar.Image(url = url) - else -> Avatar.Monogram(monogram) - }, - color = Color(item.contactColor), - ), - subject = item.subject ?: "n/a", - excerpt = item.previewText, - formattedReceivedAt = item.displayMessageDateTime, - hasAttachments = item.hasAttachments, - starred = item.isStarred, - encrypted = item.isMessageEncrypted, - answered = item.isAnswered, - forwarded = item.isForwarded, - selected = isSelected, - threadCount = item.threadCount, - active = isActive, - ) + item.toMessageItemUi(showContactPicture, isSelected, isActive, monogram, url) } -private fun MessageListItem.buildSenderStyles(): ImmutableList = buildList { +internal fun MessageListItem.toMessageItemUi( + showContactPicture: Boolean, + isSelected: Boolean, + isActive: Boolean, + monogram: String, + url: String?, +): MessageItemUi = MessageItemUi( + state = if (isRead) MessageItemUi.State.Read else MessageItemUi.State.Unread, + id = messageUid, + messageReference = messageReference.toIdentityString(), + account = Account( + id = account.id, + color = Color(account.profile.color), + ), + senders = ComposedAddressUi( + displayName = displayAddress?.address ?: "", + displayNameStyles = buildSenderStyles(), + avatar = when { + !showContactPicture -> null + showContactPicture && url != null -> Avatar.Image(url = url) + else -> Avatar.Monogram(monogram) + }, + color = Color(contactColor), + ), + subject = subject ?: "n/a", + excerpt = previewText, + formattedReceivedAt = displayMessageDateTime, + hasAttachments = hasAttachments, + starred = isStarred, + encrypted = isMessageEncrypted, + answered = isAnswered, + forwarded = isForwarded, + selected = isSelected, + threadCount = threadCount, + active = isActive, +) + +internal fun MessageListItem.buildSenderStyles(): ImmutableList = buildList { when (val separatorIndex = displayName.indexOf(',')) { -1 if !isRead -> add(ComposedAddressStyle.Bold(start = 0)) in 0..Int.MAX_VALUE if !isRead -> {