Fix tests and improve structure of CustomReactionState

- Fix tests
- Improve structure of CustomReactionState
This commit is contained in:
David Langley 2023-08-30 12:47:31 +01:00
parent 6c6aa04c96
commit a4e8f0c8fd
12 changed files with 158 additions and 43 deletions

View file

@ -23,6 +23,8 @@ import androidx.preference.PreferenceManager
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
@ -105,4 +107,10 @@ object AppModule {
fun provideSnackbarDispatcher(): SnackbarDispatcher { fun provideSnackbarDispatcher(): SnackbarDispatcher {
return SnackbarDispatcher() return SnackbarDispatcher()
} }
@Provides
@SingleIn(AppScope::class)
fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider {
return DefaultEmojibaseProvider(context)
}
} }

View file

@ -67,8 +67,7 @@ fun aMessagesState() = MessagesState(
), ),
actionListState = anActionListState(), actionListState = anActionListState(),
customReactionState = CustomReactionState( customReactionState = CustomReactionState(
selectedEventId = null, target = CustomReactionState.Target.None,
emojiProvider = Async.Uninitialized,
eventSink = {}, eventSink = {},
selectedEmoji = persistentSetOf(), selectedEmoji = persistentSetOf(),
), ),

View file

@ -200,11 +200,9 @@ fun MessagesView(
CustomReactionBottomSheet( CustomReactionBottomSheet(
state = state.customReactionState, state = state.customReactionState,
onEmojiSelected = { emoji -> onEmojiSelected = { eventId, emoji ->
state.customReactionState.selectedEventId?.let { eventId ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet) state.customReactionState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
}
} }
) )

View file

@ -23,33 +23,35 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.actionlist.ActionListState
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.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.EventId
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CustomReactionBottomSheet( fun CustomReactionBottomSheet(
state: CustomReactionState, state: CustomReactionState,
onEmojiSelected: (Emoji) -> Unit, onEmojiSelected: (EventId, Emoji) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val target = state.target as? CustomReactionState.Target.Success
fun onDismiss() { fun onDismiss() {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
} }
fun onEmojiSelectedDismiss(emoji: Emoji) { fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event?.eventId == null) return
sheetState.hide(coroutineScope) { sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onEmojiSelected(emoji) onEmojiSelected(target.event.eventId, emoji)
} }
} }
val emojiProvider = state.emojiProvider.dataOrNull() if (target?.emojibaseStore != null && target.event.eventId != null) {
val selectedEventId = state.selectedEventId
if (emojiProvider != null && selectedEventId != null) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = ::onDismiss, onDismissRequest = ::onDismiss,
sheetState = sheetState, sheetState = sheetState,
@ -57,7 +59,7 @@ fun CustomReactionBottomSheet(
) { ) {
EmojiPicker( EmojiPicker(
onEmojiSelected = ::onEmojiSelectedDismiss, onEmojiSelected = ::onEmojiSelectedDismiss,
emojiProvider = emojiProvider, emojibaseStore = target.emojibaseStore,
selectedEmojis = state.selectedEmoji, selectedEmojis = state.selectedEmoji,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )

View file

@ -17,42 +17,46 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.canReact
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import javax.inject.Inject import javax.inject.Inject
class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionState> { class CustomReactionPresenter @Inject constructor(
private val emojibaseProvider: EmojibaseProvider
) : Presenter<CustomReactionState> {
@Composable @Composable
override fun present(): CustomReactionState { override fun present(): CustomReactionState {
var selectedEvent by remember { mutableStateOf<TimelineItem.Event?>(null) } val target: MutableState<CustomReactionState.Target> = remember {
var emojiState: Async<EmojibaseStore> by remember { mutableStateOf(CustomReactionState.Target.None)
mutableStateOf(Async.Uninitialized)
} }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val context = LocalContext.current
fun handleShowCustomReactionSheet(event: TimelineItem.Event) { fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
selectedEvent = event target.value = CustomReactionState.Target.Loading(event)
emojiState = Async.Loading()
localCoroutineScope.launch { localCoroutineScope.launch {
emojiState = Async.Success(EmojibaseDatasource().load(context)) target.value = CustomReactionState.Target.Success(
event = event,
emojibaseStore = emojibaseProvider.loadEmojibase()
)
} }
} }
fun handleDismissCustomReactionSheet() { fun handleDismissCustomReactionSheet() {
selectedEvent = null target.value = CustomReactionState.Target.None
emojiState = Async.Uninitialized
} }
fun handleEvents(event: CustomReactionEvents) { fun handleEvents(event: CustomReactionEvents) {
@ -61,10 +65,10 @@ class CustomReactionPresenter @Inject constructor() : Presenter<CustomReactionSt
is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet() is CustomReactionEvents.DismissCustomReactionSheet -> handleDismissCustomReactionSheet()
} }
} }
val selectedEmoji = selectedEvent?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet() val event = (target.value as? CustomReactionState.Target.Success)?.event
val selectedEmoji = event?.reactionsState?.reactions?.mapNotNull { if(it.isHighlighted) it.key else null }.orEmpty().toImmutableSet()
return CustomReactionState( return CustomReactionState(
selectedEventId = selectedEvent?.eventId, target = target.value,
emojiProvider = emojiState,
selectedEmoji = selectedEmoji, selectedEmoji = selectedEmoji,
eventSink = ::handleEvents eventSink = ::handleEvents
) )

View file

@ -17,13 +17,27 @@
package io.element.android.features.messages.impl.timeline.components.customreaction package io.element.android.features.messages.impl.timeline.components.customreaction
import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.emojibasebindings.EmojibaseStore
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.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState( data class CustomReactionState(
val selectedEventId: EventId?, val target: Target,
val emojiProvider: Async<EmojibaseStore>,
val selectedEmoji: ImmutableSet<String>, val selectedEmoji: ImmutableSet<String>,
val eventSink: (CustomReactionEvents) -> Unit, val eventSink: (CustomReactionEvents) -> Unit,
) ) {
sealed interface Target {
data object None : Target
data class Loading(val event: TimelineItem.Event) : Target
data class Success(
val event: TimelineItem.Event,
val emojibaseStore: EmojibaseStore,
) : Target
}
}

View file

@ -0,0 +1,27 @@
/*
* 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 android.content.Context
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
class DefaultEmojibaseProvider(val context: Context) :EmojibaseProvider {
override fun loadEmojibase(): EmojibaseStore {
return EmojibaseDatasource().load(context)
}
}

View file

@ -63,12 +63,12 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun EmojiPicker( fun EmojiPicker(
onEmojiSelected: (Emoji) -> Unit, onEmojiSelected: (Emoji) -> Unit,
emojiProvider: EmojibaseStore, emojibaseStore: EmojibaseStore,
selectedEmojis: ImmutableSet<String>, selectedEmojis: ImmutableSet<String>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val categories = remember { emojiProvider.categories } val categories = remember { emojibaseStore.categories }
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.values().size }) val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.values().size })
Column(modifier) { Column(modifier) {
TabRow( TabRow(
@ -149,7 +149,7 @@ internal fun EmojiPickerDarkPreview() {
private fun ContentToPreview() { private fun ContentToPreview() {
EmojiPicker( EmojiPicker(
onEmojiSelected = {}, onEmojiSelected = {},
emojiProvider = EmojibaseDatasource().load(LocalContext.current), emojibaseStore = EmojibaseDatasource().load(LocalContext.current),
selectedEmojis = persistentSetOf("😀", "😄", "😃"), selectedEmojis = persistentSetOf("😀", "😄", "😃"),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )

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.emojibasebindings.EmojibaseStore
interface EmojibaseProvider {
fun loadEmojibase(): EmojibaseStore
}

View file

@ -41,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
@ -584,7 +585,7 @@ class MessagesPresenterTest {
) )
val buildMeta = aBuildMeta() val buildMeta = aBuildMeta()
val actionListPresenter = ActionListPresenter(buildMeta = buildMeta) val actionListPresenter = ActionListPresenter(buildMeta = buildMeta)
val customReactionPresenter = CustomReactionPresenter() val customReactionPresenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom) val reactionSummaryPresenter = ReactionSummaryPresenter(room = matrixRoom)
val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom) val retrySendMenuPresenter = RetrySendMenuPresenter(room = matrixRoom)
return MessagesPresenter( return MessagesPresenter(

View file

@ -24,27 +24,34 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents 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.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class CustomReactionPresenterTests { class CustomReactionPresenterTests {
private val presenter = CustomReactionPresenter() private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
@Test @Test
fun `present - handle selecting and de-selecting an event`() = runTest { fun `present - handle selecting and de-selecting an event`() = runTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(aTimelineItemEvent(eventId = AN_EVENT_ID))) val event = aTimelineItemEvent(eventId = AN_EVENT_ID)
assertThat(awaitItem().selectedEventId).isEqualTo(AN_EVENT_ID) val initialState = awaitItem()
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
val eventId = (awaitItem().target as? CustomReactionState.Target.Success)?.event?.eventId
assertThat(eventId).isEqualTo(AN_EVENT_ID)
initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet) initialState.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
assertThat(awaitItem().selectedEventId).isNull() assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.None)
} }
} }
@ -53,13 +60,19 @@ class CustomReactionPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem()
assertThat(initialState.selectedEventId).isNull()
val reactions = aTimelineItemReactions(count = 1, isHighlighted = true) val reactions = aTimelineItemReactions(count = 1, isHighlighted = true)
val event = aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions)
val initialState = awaitItem()
assertThat(initialState.target).isEqualTo(CustomReactionState.Target.None)
val key = reactions.reactions.first().key val key = reactions.reactions.first().key
initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(aTimelineItemEvent(eventId = AN_EVENT_ID, timelineItemReactions = reactions))) initialState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event))
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
val stateWithSelectedEmojis = awaitItem() val stateWithSelectedEmojis = awaitItem()
assertThat(stateWithSelectedEmojis.selectedEventId).isEqualTo(AN_EVENT_ID) val eventId = (stateWithSelectedEmojis.target as? CustomReactionState.Target.Success)?.event?.eventId
assertThat(eventId).isEqualTo(AN_EVENT_ID)
assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key) assertThat(stateWithSelectedEmojis.selectedEmoji).contains(key)
} }
} }

View file

@ -0,0 +1,26 @@
/*
* 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 io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider
class FakeEmojibaseProvider: EmojibaseProvider {
override fun loadEmojibase(): EmojibaseStore {
return EmojibaseStore(mapOf())
}
}