diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapper.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapper.kt index e09ad55386..f873228e2c 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapper.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapper.kt @@ -23,6 +23,7 @@ import ch.protonmail.android.maildetail.presentation.model.AutoDeleteBannerUiMod import ch.protonmail.android.maildetail.presentation.model.ExpirationBannerUiModel import ch.protonmail.android.maildetail.presentation.model.MessageBannersUiModel import ch.protonmail.android.maildetail.presentation.model.ScheduleSendBannerUiModel +import ch.protonmail.android.maildetail.presentation.model.SnoozeBannerUiModel import ch.protonmail.android.maildetail.presentation.usecase.FormatScheduleSendTime import ch.protonmail.android.mailmessage.domain.model.MessageBanner import dagger.hilt.android.qualifiers.ApplicationContext @@ -39,7 +40,8 @@ class MessageBannersUiModelMapper @Inject constructor( shouldShowBlockedSenderBanner = messageBanners.contains(MessageBanner.BlockedSender), expirationBannerUiModel = toExpirationBannerUiModel(messageBanners), autoDeleteBannerUiModel = toAutoDeleteBannerUiModel(messageBanners), - scheduleSendBannerUiModel = toScheduleSendBannerUiModel(messageBanners) + scheduleSendBannerUiModel = toScheduleSendBannerUiModel(messageBanners), + snoozeBannerUiModel = toSnoozeBannerUiModel(messageBanners) ) private fun toExpirationBannerUiModel(messageBanners: List): ExpirationBannerUiModel { @@ -62,4 +64,12 @@ class MessageBannersUiModelMapper @Inject constructor( ) } ?: ScheduleSendBannerUiModel.NoScheduleSend } + + private fun toSnoozeBannerUiModel(messageBanners: List): SnoozeBannerUiModel { + return messageBanners.filterIsInstance().firstOrNull()?.let { + SnoozeBannerUiModel.SnoozeScheduled( + snoozedUntil = formatScheduleSendTime(it.snoozedUntil) + ) + } ?: SnoozeBannerUiModel.NotSnoozed + } } diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/ConversationDetailOperation.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/ConversationDetailOperation.kt index e9ed878926..a2cc2596b1 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/ConversationDetailOperation.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/ConversationDetailOperation.kt @@ -168,6 +168,9 @@ sealed interface ConversationDetailEvent : ConversationDetailOperation { ConversationDetailEvent, AffectingErrorBar, AffectingMessages object ErrorAnsweringRsvpEvent : ConversationDetailEvent, AffectingErrorBar + + object ErrorUnsnoozing : ConversationDetailEvent, AffectingErrorBar + } sealed interface ConversationDetailViewAction : ConversationDetailOperation { @@ -333,6 +336,9 @@ sealed interface ConversationDetailViewAction : ConversationDetailOperation { data class EditScheduleSendMessageRequested(val messageId: MessageIdUiModel) : ConversationDetailViewAction, AffectingEditScheduleMessageDialog + data class OnUnsnoozeConversationRequested(val messageId: MessageIdUiModel) : + ConversationDetailViewAction, AffectingBottomSheet + data class PrintMessage(val context: Context, val messageId: MessageId) : ConversationDetailViewAction, AffectingBottomSheet diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/MessageBannersUiModel.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/MessageBannersUiModel.kt index 0659989c52..330ec4887c 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/MessageBannersUiModel.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/model/MessageBannersUiModel.kt @@ -27,7 +27,8 @@ data class MessageBannersUiModel( val shouldShowBlockedSenderBanner: Boolean, val expirationBannerUiModel: ExpirationBannerUiModel, val autoDeleteBannerUiModel: AutoDeleteBannerUiModel, - val scheduleSendBannerUiModel: ScheduleSendBannerUiModel + val scheduleSendBannerUiModel: ScheduleSendBannerUiModel, + val snoozeBannerUiModel: SnoozeBannerUiModel ) sealed class ExpirationBannerUiModel { @@ -51,3 +52,10 @@ sealed class ScheduleSendBannerUiModel { val isScheduleBeingCancelled: Boolean ) : ScheduleSendBannerUiModel() } + +sealed class SnoozeBannerUiModel { + data object NotSnoozed : SnoozeBannerUiModel() + data class SnoozeScheduled( + val snoozedUntil: TextUiModel + ) : SnoozeBannerUiModel() +} diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducer.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducer.kt index b095c2d305..45c585c95a 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducer.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducer.kt @@ -28,18 +28,30 @@ import ch.protonmail.android.maildetail.domain.model.OpenProtonCalendarIntentVal import ch.protonmail.android.maildetail.presentation.R import ch.protonmail.android.maildetail.presentation.mapper.ActionResultMapper import ch.protonmail.android.maildetail.presentation.model.ConversationDeleteState -import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.HandleOpenProtonCalendarRequest +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.OfflineErrorCancellingScheduleSend +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ExitScreenWithMessage +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ExitScreen +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.LastMessageMoved +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.MessageMoved +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorDeletingMessage +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.MessageBottomSheetEvent +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ScheduleSendCancelled +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorUnsnoozing import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ConversationBottomBarEvent import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ConversationBottomSheetEvent import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorAddStar +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorMarkingAsRead import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorAttachmentDownloadInProgress import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorDeletingConversation import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorExpandingDecryptMessageError +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorAnsweringRsvpEvent import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorExpandingRetrieveMessageError import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorExpandingRetrievingMessageOffline import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorGettingAttachment import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorGettingAttachmentNotEnoughSpace import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorLabelingConversation +import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorCancellingScheduleSend import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorMarkingAsUnread import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorMovingConversation import ch.protonmail.android.maildetail.presentation.model.ConversationDetailEvent.ErrorMovingMessage @@ -113,7 +125,7 @@ class ConversationDetailReducer @Inject constructor( private fun ConversationDetailState.toOpenComposerEffectState( operation: ConversationDetailOperation ): Effect = when (operation) { - is ConversationDetailEvent.ScheduleSendCancelled -> Effect.of(operation.messageId) + is ScheduleSendCancelled -> Effect.of(operation.messageId) else -> onExitWithNavigateToComposer } @@ -143,7 +155,7 @@ class ConversationDetailReducer @Inject constructor( if (operation is ConversationDetailOperation.AffectingBottomSheet) { val bottomSheetOperation = when (operation) { is ConversationBottomSheetEvent -> operation.bottomSheetOperation - is ConversationDetailEvent.MessageBottomSheetEvent -> operation.bottomSheetOperation + is MessageBottomSheetEvent -> operation.bottomSheetOperation is ConversationDetailViewAction.RequestContactActionsBottomSheet, is ConversationDetailViewAction.RequestConversationMoreActionsBottomSheet, is ConversationDetailViewAction.RequestMessageMoreActionsBottomSheet, @@ -156,8 +168,8 @@ class ConversationDetailReducer @Inject constructor( is ErrorLabelingConversation, is ErrorAddStar, is ErrorDeletingConversation, - is ConversationDetailEvent.ErrorDeletingMessage, - is ConversationDetailEvent.ErrorMarkingAsRead, + is ErrorDeletingMessage, + is ErrorMarkingAsRead, is ErrorMarkingAsUnread, is ErrorMovingMessage, is ErrorMovingToTrash, @@ -182,10 +194,11 @@ class ConversationDetailReducer @Inject constructor( is ConversationDetailViewAction.LabelAsCompleted, is ConversationDetailViewAction.MoveToCompleted, is ConversationDetailViewAction.PrintMessage, - is ConversationDetailEvent.MessageMoved, - is ConversationDetailEvent.LastMessageMoved, - is ConversationDetailEvent.ExitScreen, - is ConversationDetailEvent.ExitScreenWithMessage -> BottomSheetOperation.Dismiss + is MessageMoved, + is LastMessageMoved, + is ExitScreen, + is ConversationDetailViewAction.OnUnsnoozeConversationRequested, + is ExitScreenWithMessage -> BottomSheetOperation.Dismiss } bottomSheetReducer.newStateFrom(bottomSheetState, bottomSheetOperation) } else { @@ -198,7 +211,7 @@ class ConversationDetailReducer @Inject constructor( val textResource = when (operation) { is ErrorAddStar -> R.string.error_star_operation_failed is ErrorRemoveStar -> R.string.error_unstar_operation_failed - is ConversationDetailEvent.ErrorMarkingAsRead -> R.string.error_mark_as_read_failed + is ErrorMarkingAsRead -> R.string.error_mark_as_read_failed is ErrorMarkingAsUnread -> R.string.error_mark_as_unread_failed is ErrorMovingToTrash -> R.string.error_move_to_trash_failed is ErrorMovingConversation -> R.string.error_move_conversation_failed @@ -211,11 +224,12 @@ class ConversationDetailReducer @Inject constructor( is ErrorGettingAttachmentNotEnoughSpace -> R.string.error_get_attachment_not_enough_memory is ErrorAttachmentDownloadInProgress -> R.string.error_attachment_download_in_progress is ErrorDeletingConversation -> R.string.error_delete_conversation_failed - is ConversationDetailEvent.ErrorDeletingMessage -> R.string.error_delete_message_failed - is ConversationDetailEvent.ErrorCancellingScheduleSend -> R.string.error_cancel_schedule_send_failed - is ConversationDetailEvent.OfflineErrorCancellingScheduleSend -> + is ErrorUnsnoozing -> R.string.snooze_sheet_error_unable_to_unsnooze + is ErrorDeletingMessage -> R.string.error_delete_message_failed + is ErrorCancellingScheduleSend -> R.string.error_cancel_schedule_send_failed + is OfflineErrorCancellingScheduleSend -> R.string.offline_error_cancel_schedule_send_failed - is ConversationDetailEvent.ErrorAnsweringRsvpEvent -> R.string.rsvp_widget_error_answering + is ErrorAnsweringRsvpEvent -> R.string.rsvp_widget_error_answering } Effect.of(TextUiModel(textResource)) } else { @@ -240,7 +254,7 @@ class ConversationDetailReducer @Inject constructor( private fun ConversationDetailState.toExitState(operation: ConversationDetailOperation): Effect = when (operation) { - is ConversationDetailEvent.ExitScreen -> Effect.of(Unit) + is ExitScreen -> Effect.of(Unit) is ConversationDetailViewAction.ReportPhishingConfirmed -> when (messagesState) { is ConversationDetailsMessagesState.Data -> if (messagesState.messages.size > 1) { exitScreenEffect @@ -257,7 +271,7 @@ class ConversationDetailReducer @Inject constructor( private fun ConversationDetailState.toExitWithMessageState( operation: ConversationDetailOperation ): Effect = when (operation) { - is ConversationDetailEvent.ExitScreenWithMessage -> { + is ExitScreenWithMessage -> { val actionResult = actionResultMapper.toActionResult(operation.operation) if (actionResult != null) { Effect.of(actionResult) @@ -266,7 +280,7 @@ class ConversationDetailReducer @Inject constructor( } } - is ConversationDetailEvent.LastMessageMoved -> { + is LastMessageMoved -> { val actionResult = actionResultMapper.toActionResult(operation) if (actionResult != null) { Effect.of(actionResult) @@ -316,7 +330,7 @@ class ConversationDetailReducer @Inject constructor( private fun ConversationDetailState.toNewOpenProtonCalendarIntentFrom( operation: ConversationDetailOperation ): Effect = when (operation) { - is ConversationDetailEvent.HandleOpenProtonCalendarRequest -> Effect.of(operation.intent) + is HandleOpenProtonCalendarRequest -> Effect.of(operation.intent) else -> openProtonCalendarIntent } diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/sample/ConversationDetailMessageUiModelSample.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/sample/ConversationDetailMessageUiModelSample.kt index c729c6a5b7..ef4ce3223e 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/sample/ConversationDetailMessageUiModelSample.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/sample/ConversationDetailMessageUiModelSample.kt @@ -34,6 +34,7 @@ import ch.protonmail.android.maildetail.presentation.model.MessageLocationUiMode import ch.protonmail.android.maildetail.presentation.model.ParticipantUiModel import ch.protonmail.android.maildetail.presentation.model.RsvpWidgetUiModel import ch.protonmail.android.maildetail.presentation.model.ScheduleSendBannerUiModel +import ch.protonmail.android.maildetail.presentation.model.SnoozeBannerUiModel import ch.protonmail.android.maillabel.domain.sample.LabelSample import ch.protonmail.android.maillabel.presentation.model.LabelUiModel import ch.protonmail.android.mailmessage.domain.model.Message @@ -296,14 +297,16 @@ object ConversationDetailMessageUiModelSample { ) private fun messageBannersUiModel( - scheduleSendBannerUiModel: ScheduleSendBannerUiModel = ScheduleSendBannerUiModel.NoScheduleSend + scheduleSendBannerUiModel: ScheduleSendBannerUiModel = ScheduleSendBannerUiModel.NoScheduleSend, + snoozeUiModel: SnoozeBannerUiModel = SnoozeBannerUiModel.NotSnoozed ) = MessageBannersUiModel( shouldShowPhishingBanner = true, shouldShowSpamBanner = false, shouldShowBlockedSenderBanner = false, expirationBannerUiModel = ExpirationBannerUiModel.NoExpiration, autoDeleteBannerUiModel = AutoDeleteBannerUiModel.NoAutoDelete, - scheduleSendBannerUiModel = scheduleSendBannerUiModel + scheduleSendBannerUiModel = scheduleSendBannerUiModel, + snoozeBannerUiModel = snoozeUiModel ) private fun buildExpanding( diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailItem.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailItem.kt index acae66678e..4b93df33b9 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailItem.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailItem.kt @@ -292,7 +292,8 @@ private fun ColumnScope.ConversationDetailExpandedItem( uiModel.messageDetailHeaderUiModel.sender.participantAddress ) }, - onCancelScheduleMessage = { actions.onEditScheduleSendMessage(uiModel.messageId) } + onCancelScheduleMessage = { actions.onEditScheduleSendMessage(uiModel.messageId) }, + onUnsnoozeMessage = { actions.onUnsnoozeMessage(uiModel.messageId) } ) MessageBody( messageBodyUiModel = uiModel.messageBodyUiModel, @@ -394,7 +395,8 @@ object ConversationDetailItem { val onEditScheduleSendMessage: (MessageIdUiModel) -> Unit, val onRetryRsvpEventLoading: (MessageIdUiModel) -> Unit, val onAnswerRsvpEvent: (MessageIdUiModel, RsvpAnswer) -> Unit, - val onMessage: (String) -> Unit + val onMessage: (String) -> Unit, + val onUnsnoozeMessage: (MessageIdUiModel) -> Unit ) val previewActions = Actions( @@ -427,7 +429,8 @@ object ConversationDetailItem { { model: MessageIdUiModel -> }, {}, { _, _ -> }, - {} + {}, + onUnsnoozeMessage = { } ) } diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailScreen.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailScreen.kt index be9da32ebf..76142e207f 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailScreen.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/ConversationDetailScreen.kt @@ -525,7 +525,10 @@ fun ConversationDetailScreen( onAnswerRsvpEvent = { messageId, answer -> viewModel.submit(ConversationDetailViewAction.AnswerRsvpEvent(MessageId(messageId.id), answer)) }, - onMessage = actions.onComposeNewMessage + onMessage = actions.onComposeNewMessage, + onUnsnoozeMessage = { messageId -> + viewModel.submit(ConversationDetailViewAction.OnUnsnoozeConversationRequested(messageId)) + } ), scrollToMessageId = state.scrollToMessage?.id ) @@ -724,7 +727,8 @@ fun ConversationDetailScreen( onEditScheduleSendMessage = actions.onEditScheduleSendMessage, onRetryRsvpEventLoading = actions.onRetryRsvpEventLoading, onAnswerRsvpEvent = actions.onAnswerRsvpEvent, - onMessage = actions.onMessage + onMessage = actions.onMessage, + onUnsnoozeMessage = actions.onUnsnoozeMessage ) MessagesContentWithHiddenEdges( uiModels = state.messagesState.messages, @@ -1092,7 +1096,8 @@ object ConversationDetailScreen { val onExitWithOpenInComposer: (MessageIdUiModel) -> Unit, val onRetryRsvpEventLoading: (MessageIdUiModel) -> Unit, val onAnswerRsvpEvent: (MessageIdUiModel, RsvpAnswer) -> Unit, - val onMessage: (String) -> Unit + val onMessage: (String) -> Unit, + val onUnsnoozeMessage: (MessageIdUiModel) -> Unit ) { companion object { @@ -1146,7 +1151,8 @@ object ConversationDetailScreen { onExitWithOpenInComposer = {}, onRetryRsvpEventLoading = {}, onAnswerRsvpEvent = { _, _ -> }, - onMessage = {} + onMessage = {}, + onUnsnoozeMessage = {} ) } } diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageBanners.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageBanners.kt index ffa0067152..d1d5a23afb 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageBanners.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageBanners.kt @@ -29,6 +29,7 @@ import ch.protonmail.android.maildetail.presentation.model.AutoDeleteBannerUiMod import ch.protonmail.android.maildetail.presentation.model.ExpirationBannerUiModel import ch.protonmail.android.maildetail.presentation.model.MessageBannersUiModel import ch.protonmail.android.maildetail.presentation.model.ScheduleSendBannerUiModel +import ch.protonmail.android.maildetail.presentation.model.SnoozeBannerUiModel import ch.protonmail.android.maildetail.presentation.util.toFormattedAutoDeleteTime import ch.protonmail.android.maildetail.presentation.util.toFormattedExpirationTime import kotlinx.coroutines.delay @@ -42,7 +43,8 @@ fun MessageBanners( messageBannersUiModel: MessageBannersUiModel, onMarkMessageAsLegitimate: (Boolean) -> Unit, onUnblockSender: () -> Unit, - onCancelScheduleMessage: () -> Unit + onCancelScheduleMessage: () -> Unit, + onUnsnoozeMessage: () -> Unit ) { Column { if (messageBannersUiModel.shouldShowPhishingBanner) { @@ -85,6 +87,10 @@ fun MessageBanners( ScheduleSendBannerUiModel.NoScheduleSend -> Unit is ScheduleSendBannerUiModel.SendScheduled -> ScheduleSendBanner(uiModel, onCancelScheduleMessage) } + when (val uiModel = messageBannersUiModel.snoozeBannerUiModel) { + SnoozeBannerUiModel.NotSnoozed -> Unit + is SnoozeBannerUiModel.SnoozeScheduled -> SnoozeBanner(uiModel, onUnsnoozeMessage) + } if (messageBannersUiModel.shouldShowBlockedSenderBanner) { ProtonBannerWithButton( bannerText = stringResource(R.string.message_blocked_sender_banner_text), @@ -96,6 +102,25 @@ fun MessageBanners( } } +@Composable +private fun SnoozeBanner(uiModel: SnoozeBannerUiModel.SnoozeScheduled, onUnsnoozeMessage: () -> Unit) { + val bannerBaseText = stringResource(R.string.snooze_message_snoozed_until_banner_title) + val sendTimeFormatted = uiModel.snoozedUntil.string() + val bannerText = buildAnnotatedString { + append(bannerBaseText) + appendLine() + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(sendTimeFormatted) + } + } + ProtonBannerWithButton( + bannerText = bannerText, + buttonText = stringResource(R.string.snooze_message_unsnooze_banner_button), + icon = R.drawable.ic_proton_clock, + onButtonClicked = onUnsnoozeMessage + ) +} + @Composable private fun ScheduleSendBanner(uiModel: ScheduleSendBannerUiModel.SendScheduled, onCancelScheduleMessage: () -> Unit) { val bannerBaseText = stringResource(R.string.schedule_message_sent_at_banner_title) @@ -219,11 +244,15 @@ fun PreviewMessageBanners() { scheduleSendBannerUiModel = ScheduleSendBannerUiModel.SendScheduled( sendAt = TextUiModel.Text("tomorrow at 08:00"), isScheduleBeingCancelled = false + ), + snoozeBannerUiModel = SnoozeBannerUiModel.SnoozeScheduled( + snoozedUntil = TextUiModel.Text("tomorrow at 08:00") ) ), onMarkMessageAsLegitimate = {}, onUnblockSender = {}, - onCancelScheduleMessage = {} + onCancelScheduleMessage = {}, + onUnsnoozeMessage = {} ) } } diff --git a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModel.kt b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModel.kt index 29c899c630..7a71fffdd6 100644 --- a/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModel.kt +++ b/mail-detail/presentation/src/main/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModel.kt @@ -128,6 +128,7 @@ import ch.protonmail.android.mailmessage.presentation.model.bottomsheet.MoveToBo import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserId import ch.protonmail.android.mailsettings.domain.usecase.privacy.ObservePrivacySettings import ch.protonmail.android.mailsettings.domain.usecase.privacy.UpdateLinkConfirmationSetting +import ch.protonmail.android.mailsnooze.domain.SnoozeRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher @@ -204,7 +205,8 @@ class ConversationDetailViewModel @Inject constructor( private val cancelScheduleSendMessage: CancelScheduleSendMessage, private val printMessage: PrintMessage, private val getRsvpEvent: GetRsvpEvent, - private val answerRsvpEvent: AnswerRsvpEvent + private val answerRsvpEvent: AnswerRsvpEvent, + private val snoozeRepository: SnoozeRepository ) : ViewModel() { private val primaryUserId = observePrimaryUserId() @@ -366,7 +368,9 @@ class ConversationDetailViewModel @Inject constructor( is ConversationDetailViewAction.PrintMessage -> handlePrintMessage(action.context, action.messageId) is ConversationDetailViewAction.RetryRsvpEventLoading -> handleGetRsvpEvent(action.messageId, refresh = true) + is ConversationDetailViewAction.AnswerRsvpEvent -> handleAnswerRsvpEvent(action.messageId, action.answer) + is ConversationDetailViewAction.OnUnsnoozeConversationRequested -> handleUnsnoozeMessage(action.messageId) } } @@ -1318,6 +1322,20 @@ class ConversationDetailViewModel @Inject constructor( } } + private fun handleUnsnoozeMessage(messageId: MessageIdUiModel) { + viewModelScope.launch { + snoozeRepository.unSnoozeConversation( + userId = primaryUserId.first(), + labelId = openedFromLocation, + conversationIds = listOf(conversationId) + ).onLeft { error -> + emitNewStateFrom(ConversationDetailEvent.ErrorUnsnoozing) + }.onRight { + setOrRefreshMessageBody(messageId) + } + } + } + /** * A helper function that allows to perform actions that eventually cause the user to leave the screen, while * still making sure that observers are not being triggered during the execution of the action. diff --git a/mail-detail/presentation/src/main/res/values/strings.xml b/mail-detail/presentation/src/main/res/values/strings.xml index 97bdd8977d..edfb2b8eb6 100644 --- a/mail-detail/presentation/src/main/res/values/strings.xml +++ b/mail-detail/presentation/src/main/res/values/strings.xml @@ -168,4 +168,6 @@ Open in Proton Calendar Copy address Message + Snoozed until + Unsnooze diff --git a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapperTest.kt b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapperTest.kt index 96b8f2b1dc..1cb973c527 100644 --- a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapperTest.kt +++ b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/mapper/MessageBannersUiModelMapperTest.kt @@ -24,6 +24,7 @@ import ch.protonmail.android.mailcommon.presentation.model.TextUiModel import ch.protonmail.android.maildetail.presentation.model.AutoDeleteBannerUiModel import ch.protonmail.android.maildetail.presentation.model.ExpirationBannerUiModel import ch.protonmail.android.maildetail.presentation.model.ScheduleSendBannerUiModel +import ch.protonmail.android.maildetail.presentation.model.SnoozeBannerUiModel import ch.protonmail.android.maildetail.presentation.usecase.FormatScheduleSendTime import ch.protonmail.android.mailmessage.domain.model.MessageBanner import io.mockk.every @@ -189,4 +190,34 @@ class MessageBannersUiModelMapperTest { // Then assertEquals(ScheduleSendBannerUiModel.NoScheduleSend, result.scheduleSendBannerUiModel) } + + @Test + fun `should map to ui model with snooze banner when banners list contains it`() { + // Given + val expected = TextUiModel.Text("in the far future") + every { formatScheduleSendTime(Instant.DISTANT_FUTURE) } returns expected + + // When + val result = messageBannersUiModelMapper.toUiModel( + listOf(MessageBanner.Snoozed(Instant.DISTANT_FUTURE)) + ) + + // Then + assertEquals( + SnoozeBannerUiModel.SnoozeScheduled(expected), + result.snoozeBannerUiModel + ) + } + + + @Test + fun `should map to ui model with no snooze banner when banners list does not contain it`() { + // When + val result = messageBannersUiModelMapper.toUiModel( + emptyList() + ) + + // Then + assertEquals(SnoozeBannerUiModel.NotSnoozed, result.snoozeBannerUiModel) + } } diff --git a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducerTest.kt b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducerTest.kt index bc5182dc48..b9ae7f587e 100644 --- a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducerTest.kt +++ b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/reducer/ConversationDetailReducerTest.kt @@ -351,7 +351,8 @@ class ConversationDetailReducerTest( ) ), ConversationDetailEvent.MessageMoved(MailLabelText("String")) affects listOf(BottomSheet, MessageBar), - ConversationDetailEvent.ErrorMovingMessage affects listOf(BottomSheet, ErrorBar) + ConversationDetailEvent.ErrorMovingMessage affects listOf(BottomSheet, ErrorBar), + ConversationDetailEvent.ErrorUnsnoozing affects listOf(ErrorBar) ) @JvmStatic diff --git a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageDetailFooterActionsTest.kt b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageDetailFooterActionsTest.kt index 6cb7dbaae3..286290a6b5 100644 --- a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageDetailFooterActionsTest.kt +++ b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/ui/MessageDetailFooterActionsTest.kt @@ -66,7 +66,8 @@ internal class MessageDetailFooterActionsTest { onEditScheduleSendMessage = {}, onRetryRsvpEventLoading = {}, onAnswerRsvpEvent = { _, _ -> }, - onMessage = {} + onMessage = {}, + onUnsnoozeMessage = {} ) // When diff --git a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelIntegrationTest.kt b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelIntegrationTest.kt index 88eb3095cb..f270b31a5c 100644 --- a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelIntegrationTest.kt +++ b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelIntegrationTest.kt @@ -189,6 +189,7 @@ import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserId import ch.protonmail.android.mailsettings.domain.model.PrivacySettings import ch.protonmail.android.mailsettings.domain.usecase.privacy.ObservePrivacySettings import ch.protonmail.android.mailsettings.domain.usecase.privacy.UpdateLinkConfirmationSetting +import ch.protonmail.android.mailsnooze.domain.SnoozeRepository import ch.protonmail.android.testdata.action.AvailableActionsTestData import ch.protonmail.android.testdata.avatar.AvatarImageStatesTestData import ch.protonmail.android.testdata.contact.ContactSample @@ -275,6 +276,10 @@ class ConversationDetailViewModelIntegrationTest { every { this@mockk() } returns flowOf(AvatarImageStatesTestData.SampleData1) } + private val snoozeRepository = mockk { + coEvery { this@mockk.unSnoozeConversation(any(), any(), any()) } returns Unit.right() + } + // Privacy settings for link confirmation dialog private val observePrivacySettings = mockk { coEvery { this@mockk.invoke(any()) } returns flowOf( @@ -2518,7 +2523,8 @@ class ConversationDetailViewModelIntegrationTest { cancelScheduleSendMessage = cancelScheduleSendMessage, printMessage = printMessage, getRsvpEvent = getRsvpEvent, - answerRsvpEvent = answerRsvpEvent + answerRsvpEvent = answerRsvpEvent, + snoozeRepository = snoozeRepository ) private fun aMessageAttachment(id: String): AttachmentMetadata = AttachmentMetadata( diff --git a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelTest.kt b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelTest.kt index 9d2287a255..42924f0981 100644 --- a/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelTest.kt +++ b/mail-detail/presentation/src/test/kotlin/ch/protonmail/android/maildetail/presentation/viewmodel/ConversationDetailViewModelTest.kt @@ -121,6 +121,8 @@ import ch.protonmail.android.mailsession.domain.usecase.ObservePrimaryUserId import ch.protonmail.android.mailsettings.domain.model.PrivacySettings import ch.protonmail.android.mailsettings.domain.usecase.privacy.ObservePrivacySettings import ch.protonmail.android.mailsettings.domain.usecase.privacy.UpdateLinkConfirmationSetting +import ch.protonmail.android.mailsnooze.domain.SnoozeRepository +import ch.protonmail.android.mailsnooze.domain.model.UnsnoozeError import ch.protonmail.android.testdata.action.ActionUiModelTestData import ch.protonmail.android.testdata.avatar.AvatarImageStatesTestData import ch.protonmail.android.testdata.contact.ContactActionsGroupsSample @@ -323,6 +325,10 @@ class ConversationDetailViewModelTest { private val getRsvpEvent = mockk() private val answerRsvpEvent = mockk() + private val snoozeRepository = mockk { + coEvery { this@mockk.unSnoozeConversation(any(), any(), any()) } returns Unit.right() + } + private val testDispatcher: TestDispatcher by lazy { StandardTestDispatcher().apply { Dispatchers.setMain(this) } } @@ -373,7 +379,8 @@ class ConversationDetailViewModelTest { cancelScheduleSendMessage = cancelScheduleSendMessage, printMessage = printMessage, getRsvpEvent = getRsvpEvent, - answerRsvpEvent = answerRsvpEvent + answerRsvpEvent = answerRsvpEvent, + snoozeRepository = snoozeRepository ) } @@ -1904,6 +1911,58 @@ class ConversationDetailViewModelTest { return Pair(allCollapsed.map { it.messageId }, InvoiceWithLabelExpanded) } + @Test + fun `given unsnoozed successfully then message body is refreshed`() = runTest { + // given + val labelId = LabelIdSample.AllMail + val messageId = MessageIdUiModel("Id") + every { savedStateHandle.get(ConversationDetailScreen.OpenedFromLocationKey) } returns labelId.id + + // when + viewModel.state.test { + initialStateEmitted() + viewModel.submit(ConversationDetailViewAction.OnUnsnoozeConversationRequested(messageId)) + advanceUntilIdle() + // then + coVerify { snoozeRepository.unSnoozeConversation(userId, labelId, listOf(conversationId)) } + + advanceUntilIdle() + + val expected = MessageBodyTransformations.MessageDetailsDefaults.copy() + + coVerify(exactly = 1) { + getDecryptedMessageBody(userId, MessageId(messageId.id), expected) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `error message is emitted when unsnooze fails`() = runTest { + // given + coEvery { + snoozeRepository.unSnoozeConversation(any(), any(), any()) + } returns UnsnoozeError.Other().left() + coEvery { + reducer.newStateFrom( + currentState = ConversationDetailState.Loading, + operation = ConversationDetailEvent.ErrorUnsnoozing + ) + } returns ConversationDetailState.Loading.copy( + error = Effect.of(TextUiModel(string.snooze_sheet_error_unable_to_unsnooze)) + ) + + // when + viewModel.state.test { + initialStateEmitted() + viewModel.submit(ConversationDetailViewAction.OnUnsnoozeConversationRequested(MessageIdUiModel("id"))) + + // then + assertEquals(TextUiModel(string.snooze_sheet_error_unable_to_unsnooze), awaitItem().error.consume()) + cancelAndIgnoreRemainingEvents() + } + } + private fun setupLinkClickState(messageId: MessageIdUiModel, link: Uri) { coEvery { reducer.newStateFrom( diff --git a/test/test-data/src/main/kotlin/ch/protonmail/android/testdata/maildetail/MessageBannersUiModelTestData.kt b/test/test-data/src/main/kotlin/ch/protonmail/android/testdata/maildetail/MessageBannersUiModelTestData.kt index 705c8df426..c15440b476 100644 --- a/test/test-data/src/main/kotlin/ch/protonmail/android/testdata/maildetail/MessageBannersUiModelTestData.kt +++ b/test/test-data/src/main/kotlin/ch/protonmail/android/testdata/maildetail/MessageBannersUiModelTestData.kt @@ -22,6 +22,7 @@ import ch.protonmail.android.maildetail.presentation.model.AutoDeleteBannerUiMod import ch.protonmail.android.maildetail.presentation.model.ExpirationBannerUiModel import ch.protonmail.android.maildetail.presentation.model.MessageBannersUiModel import ch.protonmail.android.maildetail.presentation.model.ScheduleSendBannerUiModel +import ch.protonmail.android.maildetail.presentation.model.SnoozeBannerUiModel import kotlin.time.Instant @Suppress("LongParameterList") @@ -33,7 +34,8 @@ object MessageBannersUiModelTestData { shouldShowBlockedSenderBanner = true, expirationBannerUiModel = ExpirationBannerUiModel.Expiration(Instant.DISTANT_FUTURE), autoDeleteBannerUiModel = AutoDeleteBannerUiModel.AutoDelete(Instant.DISTANT_FUTURE), - scheduleSendUiModel = ScheduleSendBannerUiModel.NoScheduleSend + scheduleSendUiModel = ScheduleSendBannerUiModel.NoScheduleSend, + snoozeBannerUiModel = SnoozeBannerUiModel.NotSnoozed ) fun build( @@ -42,13 +44,15 @@ object MessageBannersUiModelTestData { shouldShowBlockedSenderBanner: Boolean, expirationBannerUiModel: ExpirationBannerUiModel, autoDeleteBannerUiModel: AutoDeleteBannerUiModel, - scheduleSendUiModel: ScheduleSendBannerUiModel + scheduleSendUiModel: ScheduleSendBannerUiModel, + snoozeBannerUiModel: SnoozeBannerUiModel ) = MessageBannersUiModel( shouldShowPhishingBanner = shouldShowPhishingBanner, shouldShowSpamBanner = shouldShowSpamBanner, shouldShowBlockedSenderBanner = shouldShowBlockedSenderBanner, expirationBannerUiModel = expirationBannerUiModel, autoDeleteBannerUiModel = autoDeleteBannerUiModel, - scheduleSendBannerUiModel = scheduleSendUiModel + scheduleSendBannerUiModel = scheduleSendUiModel, + snoozeBannerUiModel = snoozeBannerUiModel ) }