Merge pull request #2954 from element-hq/feature/bma/messageForwardFix

Message forward fix
This commit is contained in:
Benoit Marty 2024-05-31 11:34:55 +02:00 committed by GitHub
commit 99f59c54b2
16 changed files with 274 additions and 137 deletions

View file

@ -36,7 +36,6 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -99,7 +98,7 @@ class ForwardMessagesNode @AssistedInject constructor(
} }
} }
private fun onForwardSuccess(roomIds: ImmutableList<RoomId>) { private fun onForwardSuccess(roomIds: List<RoomId>) {
navigateUp() navigateUp()
if (roomIds.size == 1) { if (roomIds.size == 1) {
val targetRoomId = roomIds.first() val targetRoomId = roomIds.first()

View file

@ -18,15 +18,13 @@ package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.timeline.TimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider
@ -38,7 +36,7 @@ import kotlinx.coroutines.launch
class ForwardMessagesPresenter @AssistedInject constructor( class ForwardMessagesPresenter @AssistedInject constructor(
@Assisted eventId: String, @Assisted eventId: String,
private val matrixCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val timelineProvider: TimelineProvider, private val timelineProvider: TimelineProvider,
) : Presenter<ForwardMessagesState> { ) : Presenter<ForwardMessagesState> {
private val eventId: EventId = EventId(eventId) private val eventId: EventId = EventId(eventId)
@ -48,28 +46,22 @@ class ForwardMessagesPresenter @AssistedInject constructor(
fun create(eventId: String): ForwardMessagesPresenter fun create(eventId: String): ForwardMessagesPresenter
} }
private val forwardingActionState: MutableState<AsyncData<ImmutableList<RoomId>>> = mutableStateOf(AsyncData.Uninitialized) private val forwardingActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
fun onRoomSelected(roomIds: List<RoomId>) { fun onRoomSelected(roomIds: List<RoomId>) {
matrixCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState) appCoroutineScope.forwardEvent(eventId, roomIds.toPersistentList(), forwardingActionState)
} }
@Composable @Composable
override fun present(): ForwardMessagesState { override fun present(): ForwardMessagesState {
val forwardingSucceeded by remember {
derivedStateOf { forwardingActionState.value.dataOrNull() }
}
fun handleEvents(event: ForwardMessagesEvents) { fun handleEvents(event: ForwardMessagesEvents) {
when (event) { when (event) {
ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncData.Uninitialized ForwardMessagesEvents.ClearError -> forwardingActionState.value = AsyncAction.Uninitialized
} }
} }
return ForwardMessagesState( return ForwardMessagesState(
isForwarding = forwardingActionState.value.isLoading(), forwardAction = forwardingActionState.value,
error = (forwardingActionState.value as? AsyncData.Failure)?.error,
forwardingSucceeded = forwardingSucceeded,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }
@ -77,12 +69,11 @@ class ForwardMessagesPresenter @AssistedInject constructor(
private fun CoroutineScope.forwardEvent( private fun CoroutineScope.forwardEvent(
eventId: EventId, eventId: EventId,
roomIds: ImmutableList<RoomId>, roomIds: ImmutableList<RoomId>,
isForwardMessagesState: MutableState<AsyncData<ImmutableList<RoomId>>>, isForwardMessagesState: MutableState<AsyncAction<List<RoomId>>>,
) = launch { ) = launch {
isForwardMessagesState.value = AsyncData.Loading() suspend {
timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).fold( timelineProvider.getActiveTimeline().forwardEvent(eventId, roomIds).getOrThrow()
{ isForwardMessagesState.value = AsyncData.Success(roomIds) }, roomIds
{ isForwardMessagesState.value = AsyncData.Failure(it) } }.runCatchingUpdatingState(isForwardMessagesState)
)
} }
} }

View file

@ -16,13 +16,10 @@
package io.element.android.features.messages.impl.forward package io.element.android.features.messages.impl.forward
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
data class ForwardMessagesState( data class ForwardMessagesState(
// TODO Migrate to an Async val forwardAction: AsyncAction<List<RoomId>>,
val isForwarding: Boolean,
val error: Throwable?,
val forwardingSucceeded: ImmutableList<RoomId>?,
val eventSink: (ForwardMessagesEvents) -> Unit val eventSink: (ForwardMessagesEvents) -> Unit
) )

View file

@ -17,34 +17,31 @@
package io.element.android.features.messages.impl.forward package io.element.android.features.messages.impl.forward
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> { open class ForwardMessagesStateProvider : PreviewParameterProvider<ForwardMessagesState> {
override val values: Sequence<ForwardMessagesState> override val values: Sequence<ForwardMessagesState>
get() = sequenceOf( get() = sequenceOf(
aForwardMessagesState(), aForwardMessagesState(),
aForwardMessagesState( aForwardMessagesState(
isForwarding = true, forwardAction = AsyncAction.Loading,
), ),
aForwardMessagesState( aForwardMessagesState(
forwardingSucceeded = persistentListOf(RoomId("!room2:domain")), forwardAction = AsyncAction.Success(
listOf(RoomId("!room2:domain")),
)
), ),
aForwardMessagesState( aForwardMessagesState(
error = Throwable("error"), forwardAction = AsyncAction.Failure(Throwable("error")),
), ),
// Add other states here
) )
} }
fun aForwardMessagesState( fun aForwardMessagesState(
isForwarding: Boolean = false, forwardAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
error: Throwable? = null, eventSink: (ForwardMessagesEvents) -> Unit = {}
forwardingSucceeded: ImmutableList<RoomId>? = null,
) = ForwardMessagesState( ) = ForwardMessagesState(
isForwarding = isForwarding, forwardAction = forwardAction,
error = error, eventSink = eventSink
forwardingSucceeded = forwardingSucceeded,
eventSink = {}
) )

View file

@ -17,45 +17,25 @@
package io.element.android.features.messages.impl.forward package io.element.android.features.messages.impl.forward
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun ForwardMessagesView( fun ForwardMessagesView(
state: ForwardMessagesState, state: ForwardMessagesState,
onForwardSuccess: (ImmutableList<RoomId>) -> Unit, onForwardSuccess: (List<RoomId>) -> Unit,
modifier: Modifier = Modifier,
) { ) {
if (state.forwardingSucceeded != null) { AsyncActionView(
onForwardSuccess(state.forwardingSucceeded) async = state.forwardAction,
return onSuccess = {
} onForwardSuccess(it)
},
if (state.isForwarding) { onErrorDismiss = {
ProgressDialog(modifier) state.eventSink(ForwardMessagesEvents.ClearError)
} },
if (state.error != null) {
ForwardingErrorDialog(
modifier = modifier,
onDismiss = { state.eventSink(ForwardMessagesEvents.ClearError) },
)
}
}
@Composable
private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Modifier) {
ErrorDialog(
content = ErrorDialogDefaults.title,
onDismiss = onDismiss,
modifier = modifier,
) )
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
@ -28,13 +29,11 @@ import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import java.lang.IllegalStateException
class ForwardMessagesPresenterTest { class ForwardMessagesPresenterTest {
@get:Rule @get:Rule
@ -47,9 +46,7 @@ class ForwardMessagesPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isForwarding).isFalse() assertThat(initialState.forwardAction.isUninitialized()).isTrue()
assertThat(initialState.error).isNull()
assertThat(initialState.forwardingSucceeded).isNull()
} }
} }
@ -70,11 +67,10 @@ class ForwardMessagesPresenterTest {
val summary = aRoomSummaryDetails() val summary = aRoomSummaryDetails()
presenter.onRoomSelected(listOf(summary.roomId)) presenter.onRoomSelected(listOf(summary.roomId))
val forwardingState = awaitItem() val forwardingState = awaitItem()
assertThat(forwardingState.isForwarding).isTrue() assertThat(forwardingState.forwardAction.isLoading()).isTrue()
val successfulForwardState = awaitItem() val successfulForwardState = awaitItem()
assertThat(successfulForwardState.isForwarding).isFalse() assertThat(successfulForwardState.forwardAction).isEqualTo(AsyncAction.Success(listOf(summary.roomId)))
assertThat(successfulForwardState.forwardingSucceeded).isNotNull() forwardEventLambda.assertions().isCalledOnce()
assert(forwardEventLambda).isCalledOnce()
} }
} }
@ -96,11 +92,11 @@ class ForwardMessagesPresenterTest {
presenter.onRoomSelected(listOf(summary.roomId)) presenter.onRoomSelected(listOf(summary.roomId))
skipItems(1) skipItems(1)
val failedForwardState = awaitItem() val failedForwardState = awaitItem()
assertThat(failedForwardState.error).isNotNull() assertThat(failedForwardState.forwardAction.isFailure()).isTrue()
// Then clear error // Then clear error
failedForwardState.eventSink(ForwardMessagesEvents.ClearError) failedForwardState.eventSink(ForwardMessagesEvents.ClearError)
assertThat(awaitItem().error).isNull() assertThat(awaitItem().forwardAction.isUninitialized()).isTrue()
assert(forwardEventLambda).isCalledOnce() forwardEventLambda.assertions().isCalledOnce()
} }
} }
@ -111,6 +107,6 @@ class ForwardMessagesPresenterTest {
) = ForwardMessagesPresenter( ) = ForwardMessagesPresenter(
eventId = eventId.value, eventId = eventId.value,
timelineProvider = LiveTimelineProvider(fakeMatrixRoom), timelineProvider = LiveTimelineProvider(fakeMatrixRoom),
matrixCoroutineScope = coroutineScope, appCoroutineScope = coroutineScope,
) )
} }

View file

@ -0,0 +1,80 @@
/*
* Copyright (c) 2024 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.forward
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.testtags.TestTags
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressTag
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ForwardMessagesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `cancel error emits the expected event`() {
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>()
rule.setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Failure(AN_EXCEPTION),
eventSink = eventsRecorder
),
)
rule.pressTag(TestTags.dialogPositive.value)
eventsRecorder.assertSingle(ForwardMessagesEvents.ClearError)
}
@Test
fun `success invokes onForwardSuccess`() {
val data = listOf(A_ROOM_ID)
val eventsRecorder = EventsRecorder<ForwardMessagesEvents>(expectEvents = false)
ensureCalledOnceWithParam<List<RoomId>?>(data) { callback ->
rule.setForwardMessagesView(
aForwardMessagesState(
forwardAction = AsyncAction.Success(data),
eventSink = eventsRecorder
),
onForwardSuccess = callback,
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setForwardMessagesView(
state: ForwardMessagesState,
onForwardSuccess: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
ForwardMessagesView(
state = state,
onForwardSuccess = onForwardSuccess,
)
}
}

View file

@ -37,7 +37,7 @@ class ReportMessagePresenterTest {
@Test @Test
fun `presenter - initial state`() = runTest { fun `presenter - initial state`() = runTest {
val presenter = aPresenter() val presenter = createReportMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -50,7 +50,7 @@ class ReportMessagePresenterTest {
@Test @Test
fun `presenter - update reason`() = runTest { fun `presenter - update reason`() = runTest {
val presenter = aPresenter() val presenter = createReportMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -64,7 +64,7 @@ class ReportMessagePresenterTest {
@Test @Test
fun `presenter - toggle block user`() = runTest { fun `presenter - toggle block user`() = runTest {
val presenter = aPresenter() val presenter = createReportMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -82,7 +82,7 @@ class ReportMessagePresenterTest {
@Test @Test
fun `presenter - handle successful report and block user`() = runTest { fun `presenter - handle successful report and block user`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = aPresenter(matrixRoom = room) val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -99,7 +99,7 @@ class ReportMessagePresenterTest {
@Test @Test
fun `presenter - handle successful report`() = runTest { fun `presenter - handle successful report`() = runTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = aPresenter(matrixRoom = room) val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -116,7 +116,7 @@ class ReportMessagePresenterTest {
val room = FakeMatrixRoom().apply { val room = FakeMatrixRoom().apply {
givenReportContentResult(Result.failure(Exception("Failed to report content"))) givenReportContentResult(Result.failure(Exception("Failed to report content")))
} }
val presenter = aPresenter(matrixRoom = room) val presenter = createReportMessagePresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -132,7 +132,7 @@ class ReportMessagePresenterTest {
} }
} }
private fun aPresenter( private fun createReportMessagePresenter(
inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID), inputs: ReportMessagePresenter.Inputs = ReportMessagePresenter.Inputs(AN_EVENT_ID, A_USER_ID),
matrixRoom: MatrixRoom = FakeMatrixRoom(), matrixRoom: MatrixRoom = FakeMatrixRoom(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),

View file

@ -20,6 +20,7 @@ sealed interface RoomListFilter {
companion object { companion object {
/** /**
* Create a filter that matches all the given filters. * Create a filter that matches all the given filters.
* If no filters are provided, all the rooms will match.
*/ */
fun all(vararg filters: RoomListFilter): RoomListFilter { fun all(vararg filters: RoomListFilter): RoomListFilter {
return All(filters.toList()) return All(filters.toList())
@ -35,6 +36,7 @@ sealed interface RoomListFilter {
/** /**
* A filter that matches all the given filters. * A filter that matches all the given filters.
* If [filters] is empty, all the room will match.
*/ */
data class All( data class All(
val filters: List<RoomListFilter> val filters: List<RoomListFilter>

View file

@ -54,8 +54,8 @@ interface RoomListService {
): DynamicRoomList ): DynamicRoomList
/** /**
* returns a [DynamicRoomList] object of all rooms we want to display. * Returns a [DynamicRoomList] object of all rooms we want to display.
* This will exclude some rooms like the invites, or spaces. * If you want to get a filtered room list, consider using [createRoomList].
*/ */
val allRooms: DynamicRoomList val allRooms: DynamicRoomList

View file

@ -136,4 +136,10 @@ class RoomListFilterTest {
) )
assertThat(roomSummaries.filter(filter)).isEmpty() assertThat(roomSummaries.filter(filter)).isEmpty()
} }
@Test
fun `Room list filter all with empty list`() = runTest {
val filter = RoomListFilter.all()
assertThat(roomSummaries.filter(filter)).isEqualTo(roomSummaries)
}
} }

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.roomselect.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
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
@ -28,17 +29,14 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toImmutableList
class RoomSelectPresenter @AssistedInject constructor( class RoomSelectPresenter @AssistedInject constructor(
@Assisted private val mode: RoomSelectMode, @Assisted private val mode: RoomSelectMode,
private val client: MatrixClient, private val dataSource: RoomSelectSearchDataSource,
) : Presenter<RoomSelectState> { ) : Presenter<RoomSelectState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -48,22 +46,26 @@ class RoomSelectPresenter @AssistedInject constructor(
@Composable @Composable
override fun present(): RoomSelectState { override fun present(): RoomSelectState {
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.Initial()) }
val summaries by client.roomListService.allRooms.summaries.collectAsState(initial = emptyList()) LaunchedEffect(Unit) {
dataSource.load()
}
LaunchedEffect(query, summaries) { LaunchedEffect(searchQuery) {
val filteredSummaries = summaries.filterIsInstance<RoomSummary.Filled>() dataSource.setSearchQuery(searchQuery)
.map { it.details } }
.filter { it.name.orEmpty().contains(query, ignoreCase = true) }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received val roomSummaryDetailsList by dataSource.roomSummaries.collectAsState(initial = persistentListOf())
.toPersistentList()
results = if (filteredSummaries.isNotEmpty()) { val searchResults by remember {
SearchBarResultState.Results(filteredSummaries) derivedStateOf {
} else { when {
SearchBarResultState.NoResultsFound() roomSummaryDetailsList.isNotEmpty() -> SearchBarResultState.Results(roomSummaryDetailsList.toImmutableList())
isSearchActive -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Initial()
}
} }
} }
@ -80,15 +82,15 @@ class RoomSelectPresenter @AssistedInject constructor(
// } // }
} }
RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf() RoomSelectEvents.RemoveSelectedRoom -> selectedRooms = persistentListOf()
is RoomSelectEvents.UpdateQuery -> query = event.query is RoomSelectEvents.UpdateQuery -> searchQuery = event.query
RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive RoomSelectEvents.ToggleSearchActive -> isSearchActive = !isSearchActive
} }
} }
return RoomSelectState( return RoomSelectState(
mode = mode, mode = mode,
resultState = results, resultState = searchResults,
query = query, query = searchQuery,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
selectedRooms = selectedRooms, selectedRooms = selectedRooms,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2024 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.libraries.roomselect.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.api.roomlist.loadAllIncrementally
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private const val PAGE_SIZE = 30
/**
* DataSource for RoomSummaryDetails that can be filtered by a search query,
* and which only includes rooms the user has joined.
*/
class RoomSelectSearchDataSource @Inject constructor(
roomListService: RoomListService,
coroutineDispatchers: CoroutineDispatchers,
) {
private val roomList = roomListService.createRoomList(
pageSize = PAGE_SIZE,
initialFilter = RoomListFilter.all(),
source = RoomList.Source.All,
)
val roomSummaries: Flow<PersistentList<RoomSummaryDetails>> = roomList.filteredSummaries
.map { roomSummaries ->
roomSummaries
.filterIsInstance<RoomSummary.Filled>()
.map { it.details }
.filter { it.currentUserMembership == CurrentUserMembership.JOINED }
.distinctBy { it.roomId } // This should be removed once we're sure no duplicate Rooms can be received
.toPersistentList()
}
.flowOn(coroutineDispatchers.computation)
suspend fun load() = coroutineScope {
roomList.loadAllIncrementally(this)
}
suspend fun setSearchQuery(searchQuery: String) = coroutineScope {
val filter = if (searchQuery.isBlank()) {
RoomListFilter.all()
} else {
RoomListFilter.NormalizedMatchRoomName(searchQuery)
}
roomList.updateFilter(filter)
}
}

View file

@ -21,13 +21,16 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.roomselect.api.RoomSelectMode import io.element.android.libraries.roomselect.api.RoomSelectMode
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -38,7 +41,7 @@ class RoomSelectPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = aPresenter() val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -46,24 +49,18 @@ class RoomSelectPresenterTest {
assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
// Search is run automatically
val searchState = awaitItem()
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }
@Test @Test
fun `present - toggle search active`() = runTest { fun `present - toggle search active`() = runTest {
val presenter = aPresenter() val presenter = createRoomSelectPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isTrue() assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(RoomSelectEvents.ToggleSearchActive) initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
assertThat(awaitItem().isSearchActive).isFalse() assertThat(awaitItem().isSearchActive).isFalse()
} }
@ -74,43 +71,59 @@ class RoomSelectPresenterTest {
val roomListService = FakeRoomListService().apply { val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails()))) postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails())))
} }
val client = FakeMatrixClient(roomListService = roomListService) val presenter = createRoomSelectPresenter(
val presenter = aPresenter(client = client) roomListService = roomListService
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetails()))) assertThat(awaitItem().resultState as? SearchBarResultState.Results).isEqualTo(SearchBarResultState.Results(listOf(aRoomSummaryDetails())))
initialState.eventSink(RoomSelectEvents.ToggleSearchActive)
skipItems(1)
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
assertThat(
roomListService.allRooms.currentFilter.value
).isEqualTo(
RoomListFilter.NormalizedMatchRoomName("string not contained")
)
assertThat(awaitItem().query).isEqualTo("string not contained") assertThat(awaitItem().query).isEqualTo("string not contained")
roomListService.postAllRooms(
emptyList()
)
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java) assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }
@Test @Test
fun `present - select and remove a room`() = runTest { fun `present - select and remove a room`() = runTest {
val presenter = aPresenter() val roomListService = FakeRoomListService().apply {
postAllRooms(listOf(RoomSummary.Filled(aRoomSummaryDetails())))
}
val presenter = createRoomSelectPresenter(
roomListService = roomListService,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
skipItems(1)
val summary = aRoomSummaryDetails() val summary = aRoomSummaryDetails()
initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary)) initialState.eventSink(RoomSelectEvents.SetSelectedRoom(summary))
assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary)) assertThat(awaitItem().selectedRooms).isEqualTo(persistentListOf(summary))
initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom) initialState.eventSink(RoomSelectEvents.RemoveSelectedRoom)
assertThat(awaitItem().selectedRooms).isEmpty() assertThat(awaitItem().selectedRooms).isEmpty()
cancel()
} }
} }
private fun aPresenter( private fun TestScope.createRoomSelectPresenter(
mode: RoomSelectMode = RoomSelectMode.Forward, mode: RoomSelectMode = RoomSelectMode.Forward,
client: FakeMatrixClient = FakeMatrixClient(), roomListService: RoomListService = FakeRoomListService(),
) = RoomSelectPresenter( ) = RoomSelectPresenter(
mode = mode, mode = mode,
client = client, dataSource = RoomSelectSearchDataSource(
roomListService = roomListService,
coroutineDispatchers = testCoroutineDispatchers(),
),
) )
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8e4490d507c06fae92e6f05aed7c31ae5a20e2a52bc23efc7db7d6e1290dc36 oid sha256:fa56a2ab854d0d54d78ab3f25f0347b83d4a0df9f9fef64ad455b2192ee6b206
size 10807 size 10934

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a90e9ecf5cc6c042d9a72401b378ae0de46244803a9729fe339df7b7b676e603 oid sha256:31ec9cd927ccec096cddc0a76cb44260b1777081e59e466c28384607e5ed2e2d
size 8225 size 8376