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.
This commit is contained in:
Jorge Martin Espinosa 2023-06-15 14:58:18 +02:00 committed by GitHub
parent 9be0587b8b
commit 9bdf53f43d
14 changed files with 294 additions and 84 deletions

View file

@ -23,4 +23,5 @@ import io.element.android.libraries.matrix.api.core.EventId
sealed interface MessagesEvents { sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents data class SendReaction(val emoji: String, val eventId: EventId) : MessagesEvents
object Dismiss : MessagesEvents
} }

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents 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.messagecomposer.MessageComposerState
import io.element.android.features.messages.impl.timeline.TimelineEvents 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.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.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent 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 composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter, private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter,
private val retrySendMenuPresenter: RetrySendMenuPresenter, private val retrySendMenuPresenter: RetrySendMenuPresenter,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
@ -79,6 +82,7 @@ class MessagesPresenter @Inject constructor(
val composerState = composerPresenter.present() val composerState = composerPresenter.present()
val timelineState = timelinePresenter.present() val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present() val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present()
val retryState = retrySendMenuPresenter.present() val retryState = retrySendMenuPresenter.present()
val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L) val syncUpdateFlow = room.syncUpdateFlow().collectAsState(0L)
@ -108,8 +112,13 @@ class MessagesPresenter @Inject constructor(
} }
fun handleEvents(event: MessagesEvents) { fun handleEvents(event: MessagesEvents) {
when (event) { when (event) {
is MessagesEvents.HandleAction -> localCoroutineScope.handleTimelineAction(event.action, event.event, composerState) is MessagesEvents.HandleAction -> {
is MessagesEvents.SendReaction -> localCoroutineScope.sendReaction(event.emoji, event.eventId) 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( return MessagesState(
@ -119,6 +128,7 @@ class MessagesPresenter @Inject constructor(
composerState = composerState, composerState = composerState,
timelineState = timelineState, timelineState = timelineState,
actionListState = actionListState, actionListState = actionListState,
customReactionState = customReactionState,
retrySendMenuState = retryState, retrySendMenuState = retryState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,

View file

@ -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.actionlist.ActionListState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState 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.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.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
@ -33,6 +34,7 @@ data class MessagesState(
val composerState: MessageComposerState, val composerState: MessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
val actionListState: ActionListState, val actionListState: ActionListState,
val customReactionState: CustomReactionState,
val retrySendMenuState: RetrySendMenuState, val retrySendMenuState: RetrySendMenuState,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -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.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList 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.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.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.core.data.StableCharSequence
@ -54,6 +55,10 @@ fun aMessagesState() = MessagesState(
eventSink = {}, eventSink = {},
), ),
actionListState = anActionListState(), actionListState = anActionListState(),
customReactionState = CustomReactionState(
selectedEventId = null,
eventSink = {},
),
hasNetworkConnection = true, hasNetworkConnection = true,
snackbarMessage = null, snackbarMessage = null,
eventSink = {} eventSink = {}

View file

@ -35,15 +35,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView 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.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView 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.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.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.TimelineItemDebugInfo
import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState import io.element.android.libraries.matrix.api.timeline.item.event.EventSendState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import io.element.android.libraries.ui.strings.R as StringsR import io.element.android.libraries.ui.strings.R as StringsR
@ -100,13 +93,7 @@ fun MessagesView(
onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit, onItemDebugInfoClicked: (EventId, TimelineItemDebugInfo) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope()
val actionListViewBottomSheetState = rememberModalBottomSheetState()
val customReactionBottomSheetState = rememberModalBottomSheetState()
LogCompositions(tag = "MessagesScreen", msg = "Root") LogCompositions(tag = "MessagesScreen", msg = "Root")
var isMessageActionsBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
var isCustomReactionBottomSheetVisible by rememberSaveable { mutableStateOf(false) }
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments) AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
@ -126,19 +113,11 @@ fun MessagesView(
Timber.v("OnMessageLongClicked= ${event.id}") Timber.v("OnMessageLongClicked= ${event.id}")
localView.hideKeyboard() localView.hideKeyboard()
state.actionListState.eventSink(ActionListEvents.ComputeForMessage(event)) 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) { fun onActionSelected(action: TimelineItemAction, event: TimelineItem.Event) {
coroutineScope.launch { onDismissActionListBottomSheet() }
when (action) { when (action) {
is TimelineItemAction.Developer -> if (event.eventId != null && event.debugInfo != null) { is TimelineItemAction.Developer -> if (event.eventId != null) {
onItemDebugInfoClicked(event.eventId, event.debugInfo) onItemDebugInfoClicked(event.eventId, event.debugInfo)
} }
else -> state.eventSink(MessagesEvents.HandleAction(action, event)) else -> state.eventSink(MessagesEvents.HandleAction(action, event))
@ -147,7 +126,6 @@ fun MessagesView(
fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) {
if (event.eventId == null) return if (event.eventId == null) return
coroutineScope.launch { onDismissActionListBottomSheet() }
state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId)) state.eventSink(MessagesEvents.SendReaction(emoji, event.eventId))
} }
@ -189,42 +167,21 @@ fun MessagesView(
}, },
) )
var reactingToEventId: EventId? by remember { mutableStateOf(null) }
ActionListView( ActionListView(
state = state.actionListState, state = state.actionListState,
sheetState = actionListViewBottomSheetState,
isVisible = isMessageActionsBottomSheetVisible,
onDismiss = { coroutineScope.launch { onDismissActionListBottomSheet() } },
onActionSelected = ::onActionSelected, onActionSelected = ::onActionSelected,
onCustomReactionClicked = { event -> onCustomReactionClicked = { event ->
reactingToEventId = event.eventId state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(event.eventId))
coroutineScope.launch {
onDismissActionListBottomSheet()
isCustomReactionBottomSheetVisible = true
}
}, },
onEmojiReactionClicked = ::onEmojiReactionClicked, onEmojiReactionClicked = ::onEmojiReactionClicked,
) )
CustomReactionBottomSheet( CustomReactionBottomSheet(
isVisible = isCustomReactionBottomSheetVisible, state = state.customReactionState,
sheetState = customReactionBottomSheetState,
onDismiss = {
reactingToEventId = null
coroutineScope.launch {
customReactionBottomSheetState.hide()
isCustomReactionBottomSheetVisible = false
}
},
onEmojiSelected = { emoji -> onEmojiSelected = { emoji ->
val eventId = reactingToEventId state.customReactionState.selectedEventId?.let { eventId ->
if (eventId != null) {
state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId)) state.eventSink(MessagesEvents.SendReaction(emoji.unicode, eventId))
reactingToEventId = null state.customReactionState.eventSink(CustomReactionEvents.UpdateSelectedEvent(null))
coroutineScope.launch {
customReactionBottomSheetState.hide()
isCustomReactionBottomSheetVisible = false
}
} }
} }
) )

View file

@ -42,7 +42,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.Divider
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@ -82,37 +86,51 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@Composable @Composable
fun ActionListView( fun ActionListView(
state: ActionListState, state: ActionListState,
isVisible: Boolean,
onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit,
onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit,
onCustomReactionClicked: (TimelineItem.Event) -> Unit, onCustomReactionClicked: (TimelineItem.Event) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState() sheetState: SheetState = rememberModalBottomSheetState()
) { ) {
val coroutineScope = rememberCoroutineScope()
val targetItem = (state.target as? ActionListState.Target.Success)?.event val targetItem = (state.target as? ActionListState.Target.Success)?.event
fun onItemActionClicked( fun onItemActionClicked(
itemAction: TimelineItemAction itemAction: TimelineItemAction
) { ) {
if (targetItem == null) return if (targetItem == null) return
onActionSelected(itemAction, targetItem) sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
onActionSelected(itemAction, targetItem)
}
} }
fun onEmojiReactionClicked(emoji: String) { fun onEmojiReactionClicked(emoji: String) {
if (targetItem == null) return if (targetItem == null) return
onEmojiReactionClicked(emoji, targetItem) sheetState.hide(coroutineScope) {
state.eventSink(ActionListEvents.Clear)
onEmojiReactionClicked(emoji, targetItem)
}
} }
fun onCustomReactionClicked() { fun onCustomReactionClicked() {
if (targetItem == null) return 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( ModalBottomSheet(
sheetState = sheetState, sheetState = sheetState,
onDismissRequest = onDismiss onDismissRequest = ::onDismiss,
) { ) {
SheetContent( SheetContent(
state = state, state = state,

View file

@ -32,8 +32,6 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.ripple.rememberRipple 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.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.runtime.Composable 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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.coroutines.launch 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun EmojiPicker( fun EmojiPicker(

View file

@ -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()
)
}
}
}

View file

@ -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
}

View file

@ -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<CustomReactionState> {
@Composable
override fun present(): CustomReactionState {
var selectedEventId by remember { mutableStateOf<EventId?>(null) }
fun handleEvents(event: CustomReactionEvents) {
when (event) {
is CustomReactionEvents.UpdateSelectedEvent -> selectedEventId = event.eventId
}
}
return CustomReactionState(selectedEventId = selectedEventId, eventSink = ::handleEvents)
}
}

View file

@ -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,
)

View file

@ -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.MessagesEvents
import io.element.android.features.messages.impl.MessagesPresenter 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.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.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter 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.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.components.retrysendmenu.RetrySendMenuPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent 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"))) room.givenSendReactionResult(Result.failure(IllegalStateException("Failed to send reaction")))
initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID)) initialState.eventSink.invoke(MessagesEvents.SendReaction("👍", AN_EVENT_ID))
assertThat(room.sendReactionCount).isEqualTo(2) assertThat(room.sendReactionCount).isEqualTo(2)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
} }
} }
@ -102,7 +105,7 @@ class MessagesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Forward, aMessageEvent())) 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) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Copy, aMessageEvent())) 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) skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) 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) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Reply, aMessageEvent(eventId = null))) 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 // Otherwise we would have some extra items here
ensureAllEventsConsumed() ensureAllEventsConsumed()
} }
@ -177,6 +181,7 @@ class MessagesPresenterTest {
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java) assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull() 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) assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull() 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) assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
val replyMode = finalState.composerState.mode as MessageComposerMode.Reply val replyMode = finalState.composerState.mode as MessageComposerMode.Reply
assertThat(replyMode.attachmentThumbnailInfo).isNotNull() assertThat(replyMode.attachmentThumbnailInfo).isNotNull()
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
} }
} }
@ -251,6 +258,7 @@ class MessagesPresenterTest {
skipItems(1) skipItems(1)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.composerState.mode).isInstanceOf(MessageComposerMode.Edit::class.java) 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() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent())) initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Redact, aMessageEvent()))
assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID) assertThat(matrixRoom.redactEventEventIdParam).isEqualTo(AN_EVENT_ID)
assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None)
} }
} }
@ -277,7 +286,20 @@ class MessagesPresenterTest {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.ReportContent, aMessageEvent())) 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) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink.invoke(MessagesEvents.HandleAction(TimelineItemAction.Developer, aMessageEvent())) 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 = "", flavorShortDescription = "",
) )
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
val customReactionPresenter = CustomReactionPresenter()
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(
room = matrixRoom, room = matrixRoom,
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
timelinePresenter = timelinePresenter, timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter,
retrySendMenuPresenter = retrySendMenuPresenter, retrySendMenuPresenter = retrySendMenuPresenter,
networkMonitor = FakeNetworkMonitor(), networkMonitor = FakeNetworkMonitor(),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
messageSummaryFormatter = FakeMessageSummaryFormatter(), messageSummaryFormatter = FakeMessageSummaryFormatter(),
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
) )
} }
} }

View file

@ -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()
}
}
}

View file

@ -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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @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 // This preview and its screenshots are blank, see: https://issuetracker.google.com/issues/283843380
@Preview(group = PreviewGroup.BottomSheets) @Preview(group = PreviewGroup.BottomSheets)
@Composable @Composable