Merge pull request #5432 from element-hq/feature/bma/leaveSpace

Leave space: use SDK API.
This commit is contained in:
Benoit Marty
2025-10-03 16:43:45 +02:00
committed by GitHub
48 changed files with 602 additions and 210 deletions

View File

@@ -10,6 +10,7 @@ package io.element.android.features.space.impl.leave
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents {
data object Retry : LeaveSpaceEvents
data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents

View File

@@ -9,21 +9,39 @@ package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.matrix.api.MatrixClient
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class LeaveSpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LeaveSpacePresenter,
matrixClient: MatrixClient,
presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val leaveSpaceHandle = matrixClient.spaceService.getLeaveSpaceHandle(inputs.roomId)
private val presenter: LeaveSpacePresenter = presenterFactory.create(leaveSpaceHandle)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onDestroy = {
leaveSpaceHandle.close()
}
)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()

View File

@@ -8,92 +8,119 @@
package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject
@AssistedInject
class LeaveSpacePresenter(
private val spaceRoomList: SpaceRoomList,
@Assisted private val leaveSpaceHandle: LeaveSpaceHandle,
) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(leaveSpaceHandle: LeaveSpaceHandle): LeaveSpacePresenter
}
data class LeaveSpaceRooms(
val current: LeaveSpaceRoom?,
val others: List<LeaveSpaceRoom>,
)
@Composable
override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
var retryCount by remember { mutableIntStateOf(0) }
val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val selectedRoomIds = remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf())
var selectedRoomIds by remember {
mutableStateOf<Collection<RoomId>>(setOf())
}
val joinedSpaceRooms by produceState(emptyList()) {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean
val rooms = emptyList<SpaceRoom>()
// By default select all rooms
selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet()
value = rooms
var leaveSpaceRooms by remember {
mutableStateOf<AsyncData<LeaveSpaceRooms>>(AsyncData.Loading())
}
val selectableSpaceRooms by produceState<AsyncData<ImmutableList<SelectableSpaceRoom>>>(
initialValue = AsyncData.Uninitialized,
key1 = joinedSpaceRooms,
key2 = selectedRoomIds.value,
) {
value = AsyncData.Success(
joinedSpaceRooms.map {
SelectableSpaceRoom(
spaceRoom = it,
// TODO Get this value from the SDK
isLastAdmin = false,
isSelected = selectedRoomIds.value.contains(it.roomId),
LaunchedEffect(retryCount) {
val rooms = leaveSpaceHandle.rooms()
val (currentRoom, otherRooms) = rooms.getOrNull()
.orEmpty()
.partition { it.spaceRoom.roomId == leaveSpaceHandle.id }
// By default select all rooms that can be left
selectedRoomIds = otherRooms
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
leaveSpaceRooms = rooms.fold(
onSuccess = {
AsyncData.Success(
LeaveSpaceRooms(
current = currentRoom.firstOrNull(),
others = otherRooms.toImmutableList(),
)
)
}.toPersistentList()
},
onFailure = { AsyncData.Failure(it) }
)
}
var selectableSpaceRooms by remember {
mutableStateOf<AsyncData<ImmutableList<SelectableSpaceRoom>>>(AsyncData.Loading())
}
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
selectableSpaceRooms = leaveSpaceRooms.map {
it?.others.orEmpty().map { room ->
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastAdmin = room.isLastAdmin,
isSelected = selectedRoomIds.contains(room.spaceRoom.roomId),
)
}.toImmutableList()
}
}
fun handleEvents(event: LeaveSpaceEvents) {
when (event) {
LeaveSpaceEvents.Retry -> {
leaveSpaceRooms = AsyncData.Loading()
retryCount += 1
}
LeaveSpaceEvents.DeselectAllRooms -> {
selectedRoomIds.value = persistentSetOf()
selectedRoomIds = persistentSetOf()
}
LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds.value = selectableSpaceRooms.dataOrNull()
selectedRoomIds = selectableSpaceRooms.dataOrNull()
.orEmpty()
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
.toPersistentSet()
}
is LeaveSpaceEvents.ToggleRoomSelection -> {
val currentSet = selectedRoomIds.value
selectedRoomIds.value = if (currentSet.contains(event.roomId)) {
currentSet - event.roomId
selectedRoomIds = if (selectedRoomIds.contains(event.roomId)) {
selectedRoomIds - event.roomId
} else {
currentSet + event.roomId
selectedRoomIds + event.roomId
}
.toPersistentSet()
}
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds.value,
selectedRoomIds = selectedRoomIds,
)
LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized
@@ -102,7 +129,8 @@ class LeaveSpacePresenter(
}
return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name,
spaceName = leaveSpaceRooms.dataOrNull()?.current?.spaceRoom?.name,
isLastAdmin = leaveSpaceRooms.dataOrNull()?.current?.isLastAdmin == true,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents,
@@ -111,11 +139,10 @@ class LeaveSpacePresenter(
private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>,
selectedRoomIds: Collection<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space
Result.failure(Exception("Not implemented"))
leaveSpaceHandle.leave(selectedRoomIds.toList())
}
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val isLastAdmin: Boolean,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit,
@@ -25,7 +26,12 @@ data class LeaveSpaceState(
/**
* True if we should show the quick action to select/deselect all rooms.
*/
val showQuickAction = selectableRooms.isNotEmpty()
val showQuickAction = isLastAdmin.not() && selectableRooms.isNotEmpty()
/**
* True if we should show the leave button.
*/
val showLeaveButton = isLastAdmin.not() && selectableSpaceRooms is AsyncData.Success
/**
* True if there all the selectable rooms are selected.

View File

@@ -105,15 +105,20 @@ class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
),
aLeaveSpaceState(
isLastAdmin = true,
),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
isLastAdmin: Boolean = false,
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
isLastAdmin = isLastAdmin,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }

View File

@@ -9,8 +9,10 @@
package io.element.android.features.space.impl.leave
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -71,6 +73,12 @@ fun LeaveSpaceView(
) {
Scaffold(
modifier = modifier,
topBar = {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
},
containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding ->
Column(
@@ -79,45 +87,44 @@ fun LeaveSpaceView(
.imePadding()
.consumeWindowInsets(padding)
.fillMaxSize()
.padding(16.dp)
) {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
LazyColumn(
modifier = Modifier
.weight(1f),
) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
if (state.isLastAdmin.not()) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
}
}
}
}
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = null,
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = {
state.eventSink(LeaveSpaceEvents.Retry)
},
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
}
}
}
}
LeaveSpaceButtons(
showLeaveButton = state.selectableSpaceRooms is AsyncData.Success,
showLeaveButton = state.showLeaveButton,
selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
@@ -130,6 +137,7 @@ fun LeaveSpaceView(
AsyncActionView(
async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) },
)
}
@@ -150,11 +158,13 @@ private fun LeaveSpaceHeader(
modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(
R.string.screen_leave_space_title,
if (state.isLastAdmin) R.string.screen_leave_space_title_last_admin else R.string.screen_leave_space_title,
state.spaceName ?: stringResource(CommonStrings.common_space)
),
subTitle =
if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.isLastAdmin) {
stringResource(R.string.screen_leave_space_subtitle_last_admin)
} else if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
if (state.hasOnlyLastAdminRoom) {
stringResource(R.string.screen_leave_space_subtitle_only_last_admin)
} else {
@@ -166,34 +176,35 @@ private fun LeaveSpaceHeader(
)
if (state.showQuickAction) {
if (state.areAllSelected) {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_deselect_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
QuickActionButton(CommonStrings.common_deselect_all) {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
} else {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_select_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
QuickActionButton(resId = CommonStrings.common_select_all) {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
}
}
}
}
@Composable
private fun ColumnScope.QuickActionButton(
@StringRes resId: Int,
onClick: () -> Unit,
) {
Text(
modifier = Modifier
.align(Alignment.End)
.padding(end = 8.dp)
.clickable(onClick = onClick)
.padding(8.dp),
text = stringResource(resId),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
}
@Composable
private fun LeaveSpaceButtons(
showLeaveButton: Boolean,
@@ -202,7 +213,7 @@ private fun LeaveSpaceButtons(
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(top = 16.dp)
modifier = Modifier.padding(16.dp)
) {
if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) {
@@ -218,6 +229,8 @@ private fun LeaveSpaceButtons(
destructive = true,
)
}
// TODO For least admin space, add a button to open the settings.
// See https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=4622-59600
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
@@ -300,18 +313,15 @@ private fun SpaceItem(
)
}
// Number of members
val subTitle = buildString {
append(
pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
)
if (selectableSpaceRoom.isLastAdmin) {
append(" ")
append(stringResource(R.string.screen_leave_space_last_admin_info))
}
val membersCount = pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
val subTitle = if (selectableSpaceRoom.isLastAdmin) {
stringResource(R.string.screen_leave_space_last_admin_info, membersCount)
} else {
membersCount
}
Text(
modifier = Modifier.padding(end = 16.dp),

View File

@@ -202,7 +202,7 @@ private fun LoadingMoreIndicator(
private fun SpaceViewTopBar(
currentSpace: SpaceRoom?,
onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -247,23 +247,25 @@ private fun SpaceViewTopBar(
)
}
)
/*
// TODO re-enable when we have SDK APIs to leave a space
DropdownMenuItem(
onClick = {
showMenu = false
onLeaveSpaceClick()
},
text = { Text(stringResource(id = CommonStrings.action_leave)) },
text = {
Text(
text = stringResource(id = CommonStrings.action_leave),
color = ElementTheme.colors.textCriticalPrimary,
)
},
leadingIcon = {
Icon(
imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconSecondary,
tint = ElementTheme.colors.iconCriticalPrimary,
contentDescription = null,
)
}
)
*/
}
},
)

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Správce)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Opustit %1$d místnost a prostor"</item>
<item quantity="few">"Opustit %1$d místnosti a prostor"</item>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"Administrator"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Forlad %1$d rum og klynge"</item>
<item quantity="other">"Forlad %1$d rum og klynger"</item>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d Chat und Space verlassen"</item>
<item quantity="other">"%1$d Chats und Space verlassen"</item>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Quitter %1$d salon et lespace"</item>
<item quantity="other">"Quitter %1$d salons et lespace"</item>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Adminisztrátor)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"%1$d szoba és tér elhagyása"</item>
<item quantity="other">"%1$d szoba és tér elhagyása"</item>

View File

@@ -1,11 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<string name="screen_leave_space_last_admin_info">"%1$s (Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<string name="screen_leave_space_subtitle_last_admin">"You need to assign another admin for this space before you can leave."</string>
<string name="screen_leave_space_subtitle_only_last_admin">"You will not be removed from the following room(s) because you\'re the only administrator:"</string>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
<string name="screen_leave_space_title_last_admin">"You are the only admin for %1$s"</string>
</resources>

View File

@@ -5,60 +5,203 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceHandle
import io.element.android.libraries.matrix.api.spaces.LeaveSpaceRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
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_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_ID
import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeLeaveSpaceHandle
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveSpacePresenterTest {
private val aSpace = aSpaceRoom(
roomId = A_SPACE_ID,
name = A_SPACE_NAME,
)
@Test
fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter()
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
),
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.selectableSpaceRooms).isEqualTo(AsyncData.Uninitialized)
assertThat(state.isLastAdmin).isFalse()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(1)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - current space name`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList()
fun `present - fail to load rooms`() = runTest {
val presenter = createLeaveSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.failure(AN_EXCEPTION) },
)
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.spaceName).isNull()
val aSpace = aSpaceRoom(
name = A_SPACE_NAME
)
fakeSpaceRoomList.emitCurrentSpace(aSpace)
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(3)
val stateError = awaitItem()
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
// Retry
stateError.eventSink(LeaveSpaceEvents.Retry)
skipItems(1)
assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME)
val stateLoadingAgain = awaitItem()
assertThat(stateLoadingAgain.selectableSpaceRooms.isLoading()).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - current space name and is last admin`() = runTest {
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(listOf(aLeaveSpaceRoom(spaceRoom = aSpace, isLastAdmin = true))) },
)
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(3)
val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
assertThat(finalState.isLastAdmin).isTrue()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!).isEmpty()
}
}
@Test
fun `present - leave space and sub rooms`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> { Result.success(Unit) }
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = {
Result.success(
listOf(
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID), isLastAdmin = false),
LeaveSpaceRoom(aSpaceRoom(roomId = A_ROOM_ID_2), isLastAdmin = true),
)
)
},
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(4)
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.isLastAdmin).isFalse()
val data = state.selectableSpaceRooms.dataOrNull()!!
assertThat(data.size).isEqualTo(2)
// Only one room is selectable as the user is the last admin in the other one
val room1 = data[0]
assertThat(room1.spaceRoom.roomId).isEqualTo(A_ROOM_ID)
assertThat(room1.isSelected).isTrue()
assertThat(room1.isLastAdmin).isFalse()
val room2 = data[1]
assertThat(room2.spaceRoom.roomId).isEqualTo(A_ROOM_ID_2)
assertThat(room2.isSelected).isFalse()
assertThat(room2.isLastAdmin).isTrue()
// Deselect all
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
skipItems(1)
val stateAllDeselected = awaitItem()
val dataAllDeselected = stateAllDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataAllDeselected.any { it.isSelected }).isFalse()
// Select all
stateAllDeselected.eventSink(LeaveSpaceEvents.SelectAllRooms)
skipItems(1)
val stateAllSelected = awaitItem()
val dataAllSelected = stateAllSelected.selectableSpaceRooms.dataOrNull()!!
// The last admin room should not be selected
assertThat(dataAllSelected.count { it.isSelected }).isEqualTo(1)
// Toggle selection of the first room
stateAllSelected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneDeselected = awaitItem()
val dataOneDeselected = stateOneDeselected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneDeselected[0].isSelected).isFalse()
// Toggle selection of the first room
stateOneDeselected.eventSink(LeaveSpaceEvents.ToggleRoomSelection(A_ROOM_ID))
skipItems(1)
val stateOneSelected = awaitItem()
val dataOneSelected = stateOneSelected.selectableSpaceRooms.dataOrNull()!!
assertThat(dataOneSelected[0].isSelected).isTrue()
// Leave space
stateOneSelected.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateLeft = awaitItem()
assertThat(stateLeft.leaveSpaceAction.isSuccess()).isTrue()
leaveResult.assertions().isCalledOnce().with(
value(listOf(A_ROOM_ID))
)
}
}
@Test
fun `present - leave space error and close`() = runTest {
val leaveResult = lambdaRecorder<List<RoomId>, Result<Unit>> {
Result.failure(AN_EXCEPTION)
}
val presenter = createLeaveSpacePresenter(
leaveSpaceHandle = FakeLeaveSpaceHandle(
roomsResult = { Result.success(emptyList()) },
leaveResult = leaveResult,
)
)
presenter.test {
skipItems(4)
val state = awaitItem()
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()
assertThat(stateLeaving.leaveSpaceAction).isEqualTo(AsyncAction.Loading)
val stateError = awaitItem()
assertThat(stateError.leaveSpaceAction.isFailure()).isTrue()
// Close error
stateError.eventSink(LeaveSpaceEvents.CloseError)
val stateErrorClosed = awaitItem()
assertThat(stateErrorClosed.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun createLeaveSpacePresenter(
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
leaveSpaceHandle: LeaveSpaceHandle = FakeLeaveSpaceHandle(),
): LeaveSpacePresenter {
return LeaveSpacePresenter(
spaceRoomList = spaceRoomList,
leaveSpaceHandle = leaveSpaceHandle,
)
}
}
private fun aLeaveSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(
roomId = A_SPACE_ID,
name = A_SPACE_NAME,
),
isLastAdmin: Boolean = false,
) = LeaveSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
)

View File

@@ -20,6 +20,7 @@ class LeaveSpaceStateTest {
selectableSpaceRooms = AsyncData.Loading()
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
@@ -33,11 +34,29 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test last admin`() {
val sut = aLeaveSpaceState(
isLastAdmin = true,
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = false),
)
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isFalse()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no last admin, 1 selected, 1 not selected`() {
val sut = aLeaveSpaceState(
@@ -49,6 +68,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(1)
@@ -65,6 +85,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
@@ -82,6 +103,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
@@ -98,6 +120,7 @@ class LeaveSpaceStateTest {
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.showLeaveButton).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isTrue()
assertThat(sut.selectedRoomsCount).isEqualTo(0)