Avoid using runBlocking in Node resolve function.

This commit is contained in:
Benoit Marty
2025-08-14 16:41:29 +02:00
parent 7559385439
commit 16acfa28d7
10 changed files with 159 additions and 26 deletions

View File

@@ -26,16 +26,13 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.runBlocking
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class CreateRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val client: MatrixClient,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.ConfigureRoom,
@@ -59,8 +56,7 @@ class CreateRoomFlowNode @AssistedInject constructor(
createNode<ConfigureRoomNode>(buildContext, plugins = listOf(callback))
}
is NavTarget.AddPeople -> {
val joinedRoom = runBlocking { client.getJoinedRoom(navTarget.roomId) } ?: error("Room not found")
val inputs = AddPeopleNode.Inputs(joinedRoom)
val inputs = AddPeopleNode.Inputs(navTarget.roomId)
val callback: AddPeopleNode.Callback = object : AddPeopleNode.Callback {
override fun onFinish() {
onRoomCreated(navTarget.roomId)

View File

@@ -21,7 +21,7 @@ import io.element.android.features.invitepeople.api.InvitePeopleRenderer
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class AddPeopleNode @AssistedInject constructor(
@@ -31,7 +31,7 @@ class AddPeopleNode @AssistedInject constructor(
private val invitePeopleRenderer: InvitePeopleRenderer,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val joinedRoom: JoinedRoom
val roomId: RoomId,
) : NodeInputs
interface Callback : Plugin {
@@ -42,8 +42,11 @@ class AddPeopleNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onFinish() }
}
private val joinedRoom = inputs<Inputs>().joinedRoom
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(joinedRoom)
private val roomId = inputs<Inputs>().roomId
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(
joinedRoom = null,
roomId = roomId,
)
@Composable
override fun View(modifier: Modifier) {

View File

@@ -8,10 +8,14 @@
package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface InvitePeoplePresenter : Presenter<InvitePeopleState> {
interface Factory {
fun create(room: JoinedRoom): InvitePeoplePresenter
fun create(
joinedRoom: JoinedRoom?,
roomId: RoomId,
): InvitePeoplePresenter
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
@@ -23,11 +24,14 @@ import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
@@ -46,16 +50,18 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DefaultInvitePeoplePresenter @AssistedInject constructor(
@Assisted private val room: JoinedRoom,
@Assisted private val joinedRoom: JoinedRoom?,
@Assisted private val roomId: RoomId,
private val userRepository: UserRepository,
private val coroutineDispatchers: CoroutineDispatchers,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val appErrorStateService: AppErrorStateService,
private val matrixClient: MatrixClient,
) : InvitePeoplePresenter {
@AssistedFactory
@ContributesBinding(SessionScope::class)
interface Factory : InvitePeoplePresenter.Factory {
override fun create(room: JoinedRoom): DefaultInvitePeoplePresenter
override fun create(joinedRoom: JoinedRoom?, roomId: RoomId): DefaultInvitePeoplePresenter
}
@Composable
@@ -66,9 +72,21 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
if (joinedRoom == null) {
val result = matrixClient.getJoinedRoom(roomId)
value = if (result == null) {
AsyncData.Failure(Exception("Room not found"))
} else {
AsyncData.Success(result)
}
}
}
LaunchedEffect(Unit) {
fetchMembers(roomMembers)
LaunchedEffect(room.isSuccess()) {
room.dataOrNull()?.let {
fetchMembers(it, roomMembers)
}
}
LaunchedEffect(searchQuery, roomMembers) {
performSearch(
@@ -96,7 +114,9 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
searchResults.toggleUser(event.user)
}
is InvitePeopleEvents.SendInvites -> {
sessionCoroutineScope.sendInvites(selectedUsers.value)
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value)
}
}
is InvitePeopleEvents.CloseSearch -> {
searchActive = false
@@ -106,6 +126,7 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
}
return DefaultInvitePeopleState(
room = room.map { },
canInvite = selectedUsers.value.isNotEmpty(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
@@ -116,7 +137,10 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
)
}
private fun CoroutineScope.sendInvites(selectedUsers: List<MatrixUser>) = launch {
private fun CoroutineScope.sendInvites(
room: JoinedRoom,
selectedUsers: List<MatrixUser>,
) = launch {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
@@ -186,7 +210,10 @@ class DefaultInvitePeoplePresenter @AssistedInject constructor(
}.launchIn(this)
}
private suspend fun fetchMembers(roomMembers: MutableState<AsyncData<ImmutableList<RoomMember>>>) {
private suspend fun fetchMembers(
room: JoinedRoom,
roomMembers: MutableState<AsyncData<ImmutableList<RoomMember>>>
) {
suspend {
room.filterMembers("", coroutineDispatchers.io).toImmutableList()
}.runCatchingUpdatingState(roomMembers)

View File

@@ -9,11 +9,13 @@ package io.element.android.features.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class DefaultInvitePeopleState(
val room: AsyncData<Unit>,
override val canInvite: Boolean,
val searchQuery: String,
val showSearchLoader: Boolean,

View File

@@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -66,6 +67,7 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
),
showSearchLoader = true,
),
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
)
}
@@ -84,6 +86,7 @@ private fun anInvitableUser(
)
private fun aDefaultInvitePeopleState(
room: AsyncData<Unit> = AsyncData.Success(Unit),
canInvite: Boolean = false,
searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Initial(),
@@ -92,6 +95,7 @@ private fun aDefaultInvitePeopleState(
showSearchLoader: Boolean = false,
): DefaultInvitePeopleState {
return DefaultInvitePeopleState(
room = room,
canInvite = canInvite,
searchQuery = searchQuery,
searchResults = searchResults,

View File

@@ -8,19 +8,24 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -42,9 +47,39 @@ import kotlinx.collections.immutable.ImmutableList
fun InvitePeopleView(
state: DefaultInvitePeopleState,
modifier: Modifier = Modifier,
) {
when (state.room) {
is AsyncData.Failure -> InvitePeopleViewError(state.room.error, modifier)
AsyncData.Uninitialized,
is AsyncData.Loading,
is AsyncData.Success -> InvitePeopleContentView(state, modifier)
}
}
@Composable
private fun InvitePeopleViewError(
error: Throwable,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
AsyncFailure(
throwable = error,
onRetry = null,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
@Composable
private fun InvitePeopleContentView(
state: DefaultInvitePeopleState,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
InvitePeopleSearchBar(

View File

@@ -10,15 +10,21 @@ package io.element.android.features.invitepeople.impl
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomMemberList
@@ -54,7 +60,7 @@ internal class DefaultInvitePeoplePresenterTest {
val presenter = createDefaultInvitePeoplePresenter()
presenter.test {
val initialState = awaitItemAsDefault()
assertThat(initialState.room).isEqualTo(AsyncData.Success(Unit))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse()
@@ -452,6 +458,40 @@ internal class DefaultInvitePeoplePresenterTest {
}
}
@Test
fun `present - when joinedRoom is not provided, it is retrieved on the MatrixClient`() = runTest {
val matrixClient = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, FakeJoinedRoom())
}
val presenter = createDefaultInvitePeoplePresenter(
joinedRoom = null,
roomId = A_ROOM_ID,
matrixClient = matrixClient,
)
presenter.test {
val initialState = awaitItemAsDefault()
assertThat(initialState.room.isLoading()).isTrue()
val finalState = awaitItemAsDefault()
assertThat(finalState.room).isEqualTo(AsyncData.Success(Unit))
}
}
@Test
fun `present - when joinedRoom is not provided, it is retrieved on the MatrixClient - error case`() = runTest {
val matrixClient = FakeMatrixClient()
val presenter = createDefaultInvitePeoplePresenter(
joinedRoom = null,
roomId = A_ROOM_ID,
matrixClient = matrixClient,
)
presenter.test {
val initialState = awaitItemAsDefault()
assertThat(initialState.room.isLoading()).isTrue()
val finalState = awaitItemAsDefault()
assertThat(finalState.room.errorOrNull()?.message).isEqualTo("Room not found")
}
}
private suspend fun FakeUserRepository.emitStateWithUsers(
users: List<MatrixUser>,
isSearching: Boolean = false
@@ -484,19 +524,24 @@ private suspend fun <T> ReceiveTurbine<T>.awaitItemAsDefault(): DefaultInvitePeo
fun TestScope.createDefaultInvitePeoplePresenter(
roomMembersState: RoomMembersState = RoomMembersState.Ready(aRoomMemberList()),
inviteUserResult: (UserId) -> Result<Unit> = { lambdaError() },
joinedRoom: JoinedRoom? = FakeJoinedRoom(
inviteUserResult = inviteUserResult,
).apply {
givenRoomMembersState(roomMembersState)
},
roomId: RoomId = A_ROOM_ID,
userRepository: UserRepository = FakeUserRepository(),
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
appErrorStateService: AppErrorStateService = FakeAppErrorStateService(),
matrixClient: MatrixClient = FakeMatrixClient(),
): DefaultInvitePeoplePresenter {
return DefaultInvitePeoplePresenter(
room = FakeJoinedRoom(
inviteUserResult = inviteUserResult,
).apply {
givenRoomMembersState(roomMembersState)
},
joinedRoom = joinedRoom,
roomId = roomId,
userRepository = userRepository,
coroutineDispatchers = coroutineDispatchers,
coroutineScope = backgroundScope,
sessionCoroutineScope = backgroundScope,
appErrorStateService = appErrorStateService,
matrixClient = matrixClient,
)
}

View File

@@ -40,7 +40,10 @@ class RoomInviteMembersNode @AssistedInject constructor(
)
}
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(room)
private val invitePeoplePresenter = invitePeoplePresenterFactory.create(
joinedRoom = room,
roomId = room.roomId,
)
@Composable
override fun View(modifier: Modifier) {

View File

@@ -160,3 +160,17 @@ suspend inline fun <T> runUpdatingState(
}
)
}
inline fun <T, R> AsyncData<T>.map(
transform: (T?) -> R,
): AsyncData<R> {
return when (this) {
is AsyncData.Failure -> AsyncData.Failure(
error = error,
prevData = transform(prevData)
)
is AsyncData.Loading -> AsyncData.Loading(transform(prevData))
is AsyncData.Success -> AsyncData.Success(transform(data))
AsyncData.Uninitialized -> AsyncData.Uninitialized
}
}