Add an empty state for the space screen if the user can modify its graph (#6064)

* Add an empty state for the space screen if the user can modify its graph. It adds a new 'create room' button that allows you to open the create room screen with some preset values.

* When computing the editable spaces in `ConfigureRoomPresenter`, also set up the initial selected parent space if possible

* Use `Builder` pattern for `CreateRoomEntryPoint`

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-01-27 11:12:12 +01:00
committed by GitHub
parent cc4bf95cac
commit 4b4492681b
31 changed files with 248 additions and 111 deletions

View File

@@ -38,6 +38,7 @@ dependencies {
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.createroom.api)
implementation(projects.features.invite.api)
implementation(projects.libraries.previewutils)
implementation(projects.features.securityandprivacy.api)
@@ -49,5 +50,6 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.invite.test)
}

View File

@@ -24,6 +24,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode
import io.element.android.features.space.impl.di.SpaceFlowGraph
@@ -49,6 +50,7 @@ class SpaceFlowNode(
room: JoinedRoom,
spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory,
private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -71,6 +73,9 @@ class SpaceFlowNode(
@Parcelize
data object Leave : NavTarget
@Parcelize
data object CreateRoom : NavTarget
@Parcelize
data object AddRoom : NavTarget
}
@@ -116,6 +121,10 @@ class SpaceFlowNode(
backstack.push(NavTarget.Leave)
}
override fun onCreateRoom() {
backstack.push(NavTarget.CreateRoom)
}
override fun navigateToAddRoom() {
backstack.push(NavTarget.AddRoom)
}
@@ -140,6 +149,21 @@ class SpaceFlowNode(
}
createNode<SpaceSettingsFlowNode>(buildContext, listOf(callback))
}
is NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList())
}
}
createRoomEntryPoint
.builder(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
.setParentSpace(spaceRoomList.roomId)
.build()
}
NavTarget.AddRoom -> {
val callback = object : AddRoomToSpaceNode.Callback {
override fun onFinish() {

View File

@@ -47,6 +47,8 @@ class SpaceNode(
fun navigateToRoomMemberList()
fun startLeaveSpaceFlow()
fun navigateToAddRoom()
fun onCreateRoom()
}
private val callback: Callback = callback()
@@ -105,6 +107,7 @@ class SpaceNode(
modifier = Modifier
)
},
onCreateRoomClick = callback::onCreateRoom,
modifier = modifier
)
}

View File

@@ -36,7 +36,7 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
spaceInfo = aSpaceInfo(),
children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")),
hasMoreToLoad = false
hasMoreToLoad = true,
),
aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
@@ -71,7 +71,7 @@ fun aSpaceState(
joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = true,
hasMoreToLoad: Boolean = false,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden,
canAccessSpaceSettings: Boolean = true,

View File

@@ -50,7 +50,9 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@@ -65,6 +67,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
@@ -72,6 +75,7 @@ import io.element.android.libraries.designsystem.theme.components.DropdownMenuIt
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
@@ -98,6 +102,7 @@ fun SpaceView(
onLeaveSpaceClick: () -> Unit,
onSettingsClick: () -> Unit,
onViewMembersClick: () -> Unit,
onCreateRoomClick: () -> Unit,
onAddRoomClick: () -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
@@ -161,7 +166,8 @@ fun SpaceView(
},
onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic))
}
},
onCreateRoomClick = onCreateRoomClick,
)
JoinFailuresEffect(
hasAnyFailure = state.hasAnyJoinFailures,
@@ -234,6 +240,7 @@ private fun SpaceViewContent(
state: SpaceState,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit,
onCreateRoomClick: () -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
@@ -259,61 +266,90 @@ private fun SpaceViewContent(
}
}
}
itemsIndexed(
items = state.children,
key = { _, spaceRoom -> spaceRoom.roomId }
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
val isSelected = state.isSelected(spaceRoom.roomId)
val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = showUnreadIndicator,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onRoomClick(spaceRoom)
},
onLongClick = {
// TODO
},
trailingAction = if (state.isManageMode) {
{
Checkbox(
checked = isSelected,
onCheckedChange = null,
if (state.children.isEmpty() && state.canEditSpaceGraph && !state.hasMoreToLoad) {
item {
EmptySpaceView(onCreateRoomClick = onCreateRoomClick)
}
} else {
itemsIndexed(
items = state.children,
key = { _, spaceRoom -> spaceRoom.roomId }
) { index, spaceRoom ->
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
val isSelected = state.isSelected(spaceRoom.roomId)
val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = showUnreadIndicator,
hideAvatars = isInvitation && state.hideInvitesAvatar,
onClick = {
onRoomClick(spaceRoom)
},
onLongClick = {
// TODO
},
trailingAction = if (state.isManageMode) {
{
Checkbox(
checked = isSelected,
onCheckedChange = null,
)
}
} else {
spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
}
},
bottomAction = if (state.isManageMode) {
null
} else {
spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
}
} else {
spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
}
},
bottomAction = if (state.isManageMode) {
null
} else {
spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
)
if (index != state.children.lastIndex) {
HorizontalDivider()
}
}
if (state.hasMoreToLoad) {
item {
LoadingMoreIndicator(eventSink = state.eventSink)
}
)
if (index != state.children.lastIndex) {
HorizontalDivider()
}
}
if (state.hasMoreToLoad) {
item {
LoadingMoreIndicator(eventSink = state.eventSink)
}
}
}
}
@Composable
private fun EmptySpaceView(onCreateRoomClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(bottom = 24.dp),
) {
IconTitleSubtitleMolecule(
title = stringResource(R.string.screen_space_empty_state_title),
subTitle = null,
iconStyle = BigIcon.Style.Default(CompoundIcons.Room()),
modifier = Modifier.fillMaxWidth()
.padding(top = 40.dp, start = 24.dp, end = 24.dp, bottom = 24.dp),
)
Button(
text = stringResource(R.string.screen_space_add_room_action),
leadingIcon = IconSource.Vector(CompoundIcons.Plus()),
onClick = onCreateRoomClick,
)
}
}
@Composable
private fun LoadingMoreIndicator(
eventSink: (SpaceEvents) -> Unit,
@@ -611,6 +647,7 @@ internal fun SpaceViewPreview(
acceptDeclineInviteView = {},
onSettingsClick = {},
onViewMembersClick = {},
onCreateRoomClick = {},
onAddRoomClick = {},
onBackClick = {},
)

View File

@@ -12,6 +12,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.FakeCreateRoomEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
import io.element.android.libraries.matrix.api.core.RoomId
@@ -43,7 +44,8 @@ class DefaultSpaceEntryPointTest {
spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) }
),
room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory
graphFactory = FakeSpaceFlowGraph.Factory,
createRoomEntryPoint = FakeCreateRoomEntryPoint(),
)
}
val callback = object : SpaceEntryPoint.Callback {

View File

@@ -15,6 +15,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
@@ -30,6 +31,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule
@@ -200,6 +202,22 @@ class SpaceViewTest {
rule.clickOn(CommonStrings.action_remove, inDialog = true)
eventsRecorder.assertSingle(SpaceEvents.ConfirmRoomRemoval)
}
@Test
fun `clicking create room button calls the expected callback`() {
val onCreateRoomClick = lambdaRecorder<Unit> { }
rule.setSpaceView(
aSpaceState(
children = emptyList(),
hasMoreToLoad = false,
isManageMode = true,
canManageRooms = true,
),
onCreateRoomClick = onCreateRoomClick,
)
rule.clickOn(R.string.screen_space_add_room_action)
onCreateRoomClick.assertions().isCalledOnce()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(
@@ -210,6 +228,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onSettingsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onAddRoomClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {},
) {
@@ -224,6 +243,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onViewMembersClick = onViewMembersClick,
onAddRoomClick = onAddRoomClick,
acceptDeclineInviteView = acceptDeclineInviteView,
onCreateRoomClick = onCreateRoomClick,
)
}
}