From 9bdf53f43d6a1ecaaeeacd8356c38ce008aa231a Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 15 Jun 2023 14:58:18 +0200 Subject: [PATCH] Move logic of different BottomSheets in `MessagesView` to presenters (#600) * Move bottom sheet logic in `MessagesView` to presenters. * Make the block inside `SheetState.hide` suspend. --- .../features/messages/impl/MessagesEvents.kt | 1 + .../messages/impl/MessagesPresenter.kt | 14 +++- .../features/messages/impl/MessagesState.kt | 2 + .../messages/impl/MessagesStateProvider.kt | 5 ++ .../features/messages/impl/MessagesView.kt | 57 ++-------------- .../impl/actionlist/ActionListView.kt | 32 +++++++-- .../impl/timeline/components/EmojiPicker.kt | 19 ------ .../CustomReactionBottomSheet.kt | 66 +++++++++++++++++++ .../customreaction/CustomReactionEvents.kt | 23 +++++++ .../customreaction/CustomReactionPresenter.kt | 42 ++++++++++++ .../customreaction/CustomReactionState.kt | 24 +++++++ .../messages/MessagesPresenterTest.kt | 35 ++++++++-- .../CustomReactionPresenterTests.kt | 48 ++++++++++++++ .../theme/components/ModalBottomSheet.kt | 10 +++ 14 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt index 4dbf6d42d9..2d8af99fcd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -23,4 +23,5 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface MessagesEvents { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents + object Dismiss : MessagesEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 96384a295c..089f0e79cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -32,6 +33,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineEvents import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent @@ -66,6 +68,7 @@ class MessagesPresenter @Inject constructor( private val composerPresenter: MessageComposerPresenter, private val timelinePresenter: TimelinePresenter, private val actionListPresenter: ActionListPresenter, + private val customReactionPresenter: CustomReactionPresenter, private val retrySendMenuPresenter: RetrySendMenuPresenter, private val networkMonitor: NetworkMonitor, private val snackbarDispatcher: SnackbarDispatcher, @@ -79,6 +82,7 @@ class MessagesPresenter @Inject constructor( val composerState = composerPresenter.present() val timelineState = timelinePresenter.present() val actionListState = actionListPresenter.present() + val customReactionState = customReactionPresenter.present() val retryState = retrySendMenuPresenter.present() val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L) @@ -108,8 +112,13 @@ class MessagesPresenter @Inject constructor( } fun handleEvents(event: MessagesEvents) { when (event) { - is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) - is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId) + is MessagesEvents.HandleAction -> { + localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) + } + is MessagesEvents.SendReaction -> { + localCoroutineScope.sendReaction(event.emoji, event.eventId) + } + is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) } } return MessagesState( @@ -119,6 +128,7 @@ class MessagesPresenter @Inject constructor( composerState = composerState, timelineState = timelineState, actionListState = actionListState, + customReactionState = customReactionState, retrySendMenuState = retryState, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, snackbarMessage = snackbarMessage, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 7b53421b39..6e29eefe08 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState import io.element.android.features.messages.impl.timeline.TimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarMessage @@ -33,6 +34,7 @@ data class MessagesState( val composerState: MessageComposerState, val timelineState: TimelineState, val actionListState: ActionListState, + val customReactionState: CustomReactionState, val retrySendMenuState: RetrySendMenuState, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index c3cd05d823..4ed43315cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.libraries.core.data.StableCharSequence @@ -54,6 +55,10 @@ fun aMessagesState() = MessagesState( eventSink = {}, ), actionListState = anActionListState(), + customReactionState = CustomReactionState( + selectedEventId = null, + eventSink = {}, + ), hasNetworkConnection = true, snackbarMessage = null, eventSink = {} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index fb618fbba2..0687d46787 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -35,15 +35,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView @@ -61,7 +54,8 @@ import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerView import io.element.android.features.messages.impl.timeline.TimelineView -import io.element.android.features.messages.impl.timeline.components.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -84,7 +78,6 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.launch import timber.log.Timber import io.element.android.libraries.ui.strings.R as StringsR @@ -100,13 +93,7 @@ fun MessagesView( onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() - val actionListViewBottomSheetState = rememberModalBottomSheetState() - val customReactionBottomSheetState = rememberModalBottomSheetState() - LogCompositions(tag = "MessagesScreen", msg = "Root") - var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) } - var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) } AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) @@ -126,19 +113,11 @@ fun MessagesView( Timber.v("OnMessageLongClicked= ${event.id}") localView.hideKeyboard() state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) - isMessageActionsBottomSheetVisible = true - } - - suspend fun onDismissActionListBottomSheet() { - state.actionListState.eventSink(ActionListEvents.Clear) - actionListViewBottomSheetState.hide() - isMessageActionsBottomSheetVisible = false } fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) { - coroutineScope.launch { onDismissActionListBottomSheet() } when (action) { - is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) { + is TimelineItemAction.Developer -> if (event.eventId != null) { onItemDebugInfoClicked(event.eventId, event.debugInfo) } else -> state.eventSink(MessagesEvents.HandleAction(action, event)) @@ -147,7 +126,6 @@ fun MessagesView( fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return - coroutineScope.launch { onDismissActionListBottomSheet() } state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) } @@ -189,42 +167,21 @@ fun MessagesView( }, ) - var reactingToEventId: EventId? by remember { mutableStateOf(null) } ActionListView( state = state.actionListState, - sheetState = actionListViewBottomSheetState, - isVisible = isMessageActionsBottomSheetVisible, - onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } }, onActionSelected = ::onActionSelected, onCustomReactionClicked = { event -> - reactingToEventId = event.eventId - coroutineScope.launch { - onDismissActionListBottomSheet() - isCustomReactionBottomSheetVisible = true - } + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId)) }, onEmojiReactionClicked = ::onEmojiReactionClicked, ) CustomReactionBottomSheet( - isVisible = isCustomReactionBottomSheetVisible, - sheetState = customReactionBottomSheetState, - onDismiss = { - reactingToEventId = null - coroutineScope.launch { - customReactionBottomSheetState.hide() - isCustomReactionBottomSheetVisible = false - } - }, + state = state.customReactionState, onEmojiSelected = { emoji -> - val eventId = reactingToEventId - if (eventId != null) { + state.customReactionState.selectedEventId?.let { eventId -> state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) - reactingToEventId = null - coroutineScope.launch { - customReactionBottomSheetState.hide() - isCustomReactionBottomSheetVisible = false - } + state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) } } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 27ec05fb51..4a88a23fa2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -42,7 +42,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -74,6 +77,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Divider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @@ -82,37 +86,51 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @Composable fun ActionListView( state: ActionListState, - isVisible: Boolean, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, onCustomReactionClicked: (TimelineItem.Event) -> Unit, - onDismiss: () -> Unit, modifier: Modifier = Modifier, sheetState: SheetState = rememberModalBottomSheetState() ) { + val coroutineScope = rememberCoroutineScope() val targetItem = (state.target as? ActionListState.Target.Success)?.event fun onItemActionClicked( itemAction: TimelineItemAction ) { if (targetItem == null) return - onActionSelected(itemAction, targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onActionSelected(itemAction, targetItem) + } } fun onEmojiReactionClicked(emoji: String) { if (targetItem == null) return - onEmojiReactionClicked(emoji, targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onEmojiReactionClicked(emoji, targetItem) + } } fun onCustomReactionClicked() { if (targetItem == null) return - onCustomReactionClicked(targetItem) + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + onCustomReactionClicked(targetItem) + } } - if (isVisible) { + fun onDismiss() { + sheetState.hide(coroutineScope) { + state.eventSink(ActionListEvents.Clear) + } + } + + if (targetItem != null) { ModalBottomSheet( sheetState = sheetState, - onDismissRequest = onDismiss + onDismissRequest = ::onDismiss, ) { SheetContent( state = state, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt index ca96df7e7d..8c1d5e2f31 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/EmojiPicker.kt @@ -32,8 +32,6 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.runtime.Composable @@ -49,26 +47,9 @@ import com.vanniktech.emoji.google.GoogleEmojiProvider import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.Text import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CustomReactionBottomSheet( - isVisible: Boolean, - sheetState: SheetState, - onDismiss: () -> Unit, - onEmojiSelected: (Emoji) -> Unit, - modifier: Modifier = Modifier, -) { - if (isVisible) { - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { - EmojiPicker(onEmojiSelected = onEmojiSelected, modifier = Modifier.fillMaxSize()) - } - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable fun EmojiPicker( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt new file mode 100644 index 0000000000..6ea290cdb8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.vanniktech.emoji.Emoji +import io.element.android.features.messages.impl.timeline.components.EmojiPicker +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet +import io.element.android.libraries.designsystem.theme.components.hide + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomReactionBottomSheet( + state: CustomReactionState, + onEmojiSelected: (Emoji) -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + fun onDismiss() { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + } + } + + fun onEmojiSelectedDismiss(emoji: Emoji) { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + onEmojiSelected(emoji) + } + } + + val isVisible = state.selectedEventId != null + if (isVisible) { + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier + ) { + EmojiPicker( + onEmojiSelected = ::onEmojiSelectedDismiss, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt new file mode 100644 index 0000000000..b7c210553e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +sealed interface CustomReactionEvents { + data class UpdateSelectedEvent(val eventId: EventId?) : CustomReactionEvents +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt new file mode 100644 index 0000000000..0a23d42085 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.core.EventId +import javax.inject.Inject + +class CustomReactionPresenter @Inject constructor() : Presenter { + + @Composable + override fun present(): CustomReactionState { + var selectedEventId by remember { mutableStateOf(null) } + + fun handleEvents(event: CustomReactionEvents) { + when (event) { + is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId + } + } + + return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt new file mode 100644 index 0000000000..6c0c7f3599 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.impl.timeline.components.customreaction + +import io.element.android.libraries.matrix.api.core.EventId + +data class CustomReactionState( + val selectedEventId: EventId?, + val eventSink: (CustomReactionEvents) -> Unit, +) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 7d9c8a29de..ec25f2ca62 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -26,9 +26,11 @@ import io.element.android.features.messages.fixtures.aTimelineItemsFactory import io.element.android.features.messages.impl.MessagesEvents import io.element.android.features.messages.impl.MessagesPresenter import io.element.android.features.messages.impl.actionlist.ActionListPresenter +import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuPresenter import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent @@ -90,6 +92,7 @@ class MessagesPresenterTest { room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction"))) initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) assertThat(room.sendReactionCount).isEqualTo(2) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -102,7 +105,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -115,7 +118,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -131,6 +134,7 @@ class MessagesPresenterTest { skipItems(1) val finalState = awaitItem() assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -143,7 +147,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) - skipItems(1) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) // Otherwise we would have some extra items here ensureAllEventsConsumed() } @@ -177,6 +181,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -209,6 +214,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -236,6 +242,7 @@ class MessagesPresenterTest { assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) val replyMode = finalState.composerState.mode as MessageComposerMode.Reply assertThat(replyMode.attachmentThumbnailInfo).isNotNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -251,6 +258,7 @@ class MessagesPresenterTest { skipItems(1) val finalState = awaitItem() assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -265,6 +273,7 @@ class MessagesPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -277,7 +286,20 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + + @Test + fun `present - handle dismiss action`() = runTest { + val presenter = createMessagePresenter() + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(MessagesEvents.Dismiss) + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -290,7 +312,7 @@ class MessagesPresenterTest { skipItems(1) val initialState = awaitItem() initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) - // Still a TODO in the code + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) } } @@ -324,18 +346,19 @@ class MessagesPresenterTest { flavorShortDescription = "", ) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) + val customReactionPresenter = CustomReactionPresenter() val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) return MessagesPresenter( room = matrixRoom, composerPresenter = messageComposerPresenter, timelinePresenter = timelinePresenter, actionListPresenter = actionListPresenter, + customReactionPresenter = customReactionPresenter, retrySendMenuPresenter = retrySendMenuPresenter, networkMonitor = FakeNetworkMonitor(), snackbarDispatcher = SnackbarDispatcher(), messageSummaryFormatter = FakeMessageSummaryFormatter(), dispatchers = testCoroutineDispatchers(), ) - } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt new file mode 100644 index 0000000000..237cb81d38 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/components/customreaction/CustomReactionPresenterTests.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.messages.timeline.components.customreaction + +import app.cash.molecule.RecompositionClock +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class CustomReactionPresenterTests { + + private val presenter = CustomReactionPresenter() + + @Test + fun `present - handle selecting and de-selecting an event`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.selectedEventId).isNull() + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(AN_EVENT_ID)) + assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID) + + initialState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null)) + assertThat(awaitItem().selectedEventId).isNull() + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt index 0c98caaa7d..f7ca7ba1ce 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -66,6 +68,14 @@ fun ModalBottomSheet( ) } +@OptIn(ExperimentalMaterial3Api::class) +fun SheetState.hide(coroutineScope: CoroutineScope, then: suspend () -> Unit) { + coroutineScope.launch { + hide() + then() + } +} + // This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380 @Preview(group = PreviewGroup.BottomSheets) @Composable