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 fc8afab1bb
commit 6a9d084a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 248 additions and 111 deletions

View file

@ -484,7 +484,10 @@ class LoggedInFlowNode(
backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList())) backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList()))
} }
} }
createRoomEntryPoint.createNode(isSpace = true, parentNode = this, buildContext = buildContext, callback = callback) createRoomEntryPoint
.builder(parentNode = this, buildContext = buildContext, callback = callback)
.setIsSpace(true)
.build()
} }
is NavTarget.SecureBackup -> { is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode( secureBackupEntryPoint.createNode(

View file

@ -15,12 +15,13 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint { interface CreateRoomEntryPoint : FeatureEntryPoint {
fun createNode( interface Builder {
isSpace: Boolean, fun setIsSpace(isSpace: Boolean): Builder
parentNode: Node, fun setParentSpace(parentSpaceId: RoomId): Builder
buildContext: BuildContext, fun build(): Node
callback: Callback, }
): Node
fun builder(parentNode: Node, buildContext: BuildContext, callback: Callback): Builder
interface Callback : Plugin { interface Callback : Plugin {
fun onRoomCreated(roomId: RoomId) fun onRoomCreated(roomId: RoomId)

View file

@ -38,7 +38,7 @@ class CreateRoomFlowNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
) : BaseFlowNode<CreateRoomFlowNode.NavTarget>( ) : BaseFlowNode<CreateRoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance<Inputs>().first().isSpace), initialElement = initialElementFromInputs(plugins.filterIsInstance<Inputs>().first()),
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -46,7 +46,8 @@ class CreateRoomFlowNode(
) { ) {
@Parcelize @Parcelize
data class Inputs( data class Inputs(
val isSpace: Boolean val isSpace: Boolean,
val parentSpaceId: RoomId?,
) : NodeInputs, Parcelable ) : NodeInputs, Parcelable
private val callback: CreateRoomEntryPoint.Callback = callback() private val callback: CreateRoomEntryPoint.Callback = callback()
@ -54,7 +55,7 @@ class CreateRoomFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
is NavTarget.ConfigureRoom -> { is NavTarget.ConfigureRoom -> {
val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace) val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace, parentSpaceId = navTarget.parentSpaceId)
val callback = object : ConfigureRoomNode.Callback { val callback = object : ConfigureRoomNode.Callback {
override fun onCreateRoomSuccess(roomId: RoomId) { override fun onCreateRoomSuccess(roomId: RoomId) {
backstack.replace(NavTarget.AddPeople(roomId)) backstack.replace(NavTarget.AddPeople(roomId))
@ -81,9 +82,14 @@ class CreateRoomFlowNode(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data class ConfigureRoom(val isSpace: Boolean) : NavTarget data class ConfigureRoom(val isSpace: Boolean, val parentSpaceId: RoomId?) : NavTarget
@Parcelize @Parcelize
data class AddPeople(val roomId: RoomId) : NavTarget data class AddPeople(val roomId: RoomId) : NavTarget
} }
} }
private fun initialElementFromInputs(inputs: CreateRoomFlowNode.Inputs) = CreateRoomFlowNode.NavTarget.ConfigureRoom(
isSpace = inputs.isSpace,
parentSpaceId = inputs.parentSpaceId,
)

View file

@ -14,16 +14,35 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint { class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode( class Builder(
isSpace: Boolean, private val parentNode: Node,
parentNode: Node, private val buildContext: BuildContext,
buildContext: BuildContext, private val callback: CreateRoomEntryPoint.Callback,
callback: CreateRoomEntryPoint.Callback, ) : CreateRoomEntryPoint.Builder {
): Node { private var isSpace = false
val inputs = CreateRoomFlowNode.Inputs(isSpace) private var parentSpaceId: RoomId? = null
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(inputs, callback))
override fun setIsSpace(isSpace: Boolean): Builder {
this.isSpace = isSpace
return this
}
override fun setParentSpace(parentSpaceId: RoomId): Builder {
this.parentSpaceId = parentSpaceId
return this
}
override fun build(): Node {
val inputs = CreateRoomFlowNode.Inputs(isSpace = isSpace, parentSpaceId = parentSpaceId)
return parentNode.createNode<CreateRoomFlowNode>(buildContext, listOf(inputs, callback))
}
}
override fun builder(parentNode: Node, buildContext: BuildContext, callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.Builder {
return Builder(parentNode, buildContext, callback)
} }
} }

View file

@ -42,11 +42,12 @@ class ConfigureRoomNode(
@Parcelize @Parcelize
data class Inputs( data class Inputs(
val isSpace: Boolean, val isSpace: Boolean,
val parentSpaceId: RoomId?,
) : NodeInputs, Parcelable ) : NodeInputs, Parcelable
private val inputs = inputs<Inputs>() private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.isSpace) private val presenter = presenterFactory.create(inputs.isSpace, inputs.parentSpaceId)
init { init {
lifecycle.subscribe( lifecycle.subscribe(

View file

@ -63,6 +63,7 @@ import kotlin.time.Duration.Companion.seconds
@AssistedInject @AssistedInject
class ConfigureRoomPresenter( class ConfigureRoomPresenter(
@Assisted private val isSpace: Boolean, @Assisted private val isSpace: Boolean,
@Assisted private val initialParentSpaceId: RoomId?,
private val dataStore: CreateRoomConfigStore, private val dataStore: CreateRoomConfigStore,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
@ -75,7 +76,7 @@ class ConfigureRoomPresenter(
) : Presenter<ConfigureRoomState> { ) : Presenter<ConfigureRoomState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(isSpace: Boolean): ConfigureRoomPresenter fun create(isSpace: Boolean, parentSpaceId: RoomId?): ConfigureRoomPresenter
} }
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
@ -122,6 +123,9 @@ class ConfigureRoomPresenter(
} else { } else {
persistentListOf() persistentListOf()
} }
val parentSpace = spaces.find { it.roomId == initialParentSpaceId }
parentSpace?.let { dataStore.setParentSpace(it) }
} }
LaunchedEffect(cameraPermissionState.permissionGranted) { LaunchedEffect(cameraPermissionState.permissionGranted) {

View file

@ -7,6 +7,8 @@
<string name="screen_create_room_name_placeholder">"Add name…"</string> <string name="screen_create_room_name_placeholder">"Add name…"</string>
<string name="screen_create_room_new_room_title">"New room"</string> <string name="screen_create_room_new_room_title">"New room"</string>
<string name="screen_create_room_new_space_title">"New space"</string> <string name="screen_create_room_new_space_title">"New space"</string>
<string name="screen_create_room_parent_space_home_description">"(no space)"</string>
<string name="screen_create_room_parent_space_home_title">"Home"</string>
<string name="screen_create_room_private_option_description">"Only people invited can join."</string> <string name="screen_create_room_private_option_description">"Only people invited can join."</string>
<string name="screen_create_room_private_option_title">"Private"</string> <string name="screen_create_room_private_option_title">"Private"</string>
<string name="screen_create_room_public_option_description">"Anyone can find this room. <string name="screen_create_room_public_option_description">"Anyone can find this room.

View file

@ -532,6 +532,7 @@ class ConfigureRoomPresenterTest {
private fun createConfigureRoomPresenter( private fun createConfigureRoomPresenter(
isSpace: Boolean = false, isSpace: Boolean = false,
initialParenSpaceId: RoomId? = null,
roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(), roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(),
dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper), dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper),
matrixClient: MatrixClient = createMatrixClient(), matrixClient: MatrixClient = createMatrixClient(),
@ -543,6 +544,7 @@ class ConfigureRoomPresenterTest {
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
) = ConfigureRoomPresenter( ) = ConfigureRoomPresenter(
isSpace = isSpace, isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
dataStore = dataStore, dataStore = dataStore,
matrixClient = matrixClient, matrixClient = matrixClient,
mediaPickerProvider = pickerProvider, mediaPickerProvider = pickerProvider,

View file

@ -14,6 +14,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
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.A_ROOM_ID
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule import org.junit.Rule
@ -36,15 +37,16 @@ class DefaultCreateRoomEntryPointTest {
plugins = plugins, plugins = plugins,
) )
} }
val buildContext = BuildContext.root(null)
val callback = object : CreateRoomEntryPoint.Callback { val callback = object : CreateRoomEntryPoint.Callback {
override fun onRoomCreated(roomId: RoomId) = lambdaError() override fun onRoomCreated(roomId: RoomId) = lambdaError()
} }
val result = entryPoint.createNode( val result = entryPoint
isSpace = false, .builder(parentNode, buildContext, callback)
parentNode = parentNode, .setIsSpace(true)
buildContext = BuildContext.root(null), .setParentSpace(A_ROOM_ID)
callback = callback, .build()
)
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
} }
} }

View file

@ -16,5 +16,6 @@ android {
dependencies { dependencies {
implementation(projects.features.createroom.api) implementation(projects.features.createroom.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils) implementation(projects.tests.testutils)
} }

View file

@ -10,13 +10,19 @@ package io.element.android.features.createroom.api
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeCreateRoomEntryPoint : CreateRoomEntryPoint { class FakeCreateRoomEntryPoint : CreateRoomEntryPoint {
override fun createNode( class Builder : CreateRoomEntryPoint.Builder {
isSpace: Boolean, override fun setIsSpace(isSpace: Boolean): Builder = this
override fun setParentSpace(parentSpaceId: RoomId): Builder = this
override fun build(): Node = lambdaError()
}
override fun builder(
parentNode: Node, parentNode: Node,
buildContext: BuildContext, buildContext: BuildContext,
callback: CreateRoomEntryPoint.Callback, callback: CreateRoomEntryPoint.Callback,
): Node = lambdaError() ): Builder = lambdaError()
} }

View file

@ -38,6 +38,7 @@ dependencies {
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api) implementation(projects.libraries.featureflag.api)
implementation(projects.features.createroom.api)
implementation(projects.features.invite.api) implementation(projects.features.invite.api)
implementation(projects.libraries.previewutils) implementation(projects.libraries.previewutils)
implementation(projects.features.securityandprivacy.api) implementation(projects.features.securityandprivacy.api)
@ -49,5 +50,6 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.createroom.test)
testImplementation(projects.features.invite.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.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.api.SpaceEntryPoint
import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode import io.element.android.features.space.impl.addroom.AddRoomToSpaceNode
import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.di.SpaceFlowGraph
@ -49,6 +50,7 @@ class SpaceFlowNode(
room: JoinedRoom, room: JoinedRoom,
spaceService: SpaceService, spaceService: SpaceService,
graphFactory: SpaceFlowGraph.Factory, graphFactory: SpaceFlowGraph.Factory,
private val createRoomEntryPoint: CreateRoomEntryPoint,
) : BaseFlowNode<SpaceFlowNode.NavTarget>( ) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Root, initialElement = NavTarget.Root,
@ -71,6 +73,9 @@ class SpaceFlowNode(
@Parcelize @Parcelize
data object Leave : NavTarget data object Leave : NavTarget
@Parcelize
data object CreateRoom : NavTarget
@Parcelize @Parcelize
data object AddRoom : NavTarget data object AddRoom : NavTarget
} }
@ -116,6 +121,10 @@ class SpaceFlowNode(
backstack.push(NavTarget.Leave) backstack.push(NavTarget.Leave)
} }
override fun onCreateRoom() {
backstack.push(NavTarget.CreateRoom)
}
override fun navigateToAddRoom() { override fun navigateToAddRoom() {
backstack.push(NavTarget.AddRoom) backstack.push(NavTarget.AddRoom)
} }
@ -140,6 +149,21 @@ class SpaceFlowNode(
} }
createNode<SpaceSettingsFlowNode>(buildContext, listOf(callback)) 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 -> { NavTarget.AddRoom -> {
val callback = object : AddRoomToSpaceNode.Callback { val callback = object : AddRoomToSpaceNode.Callback {
override fun onFinish() { override fun onFinish() {

View file

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

View file

@ -36,7 +36,7 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
spaceInfo = aSpaceInfo(), spaceInfo = aSpaceInfo(),
children = aListOfSpaceRooms(), children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")), joiningRooms = setOf(RoomId("!spaceId0:example.com")),
hasMoreToLoad = false hasMoreToLoad = true,
), ),
aSpaceState( aSpaceState(
topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()), topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()),
@ -71,7 +71,7 @@ fun aSpaceState(
joiningRooms: Set<RoomId> = emptySet(), joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading }, joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = true, hasMoreToLoad: Boolean = false,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
topicViewerState: TopicViewerState = TopicViewerState.Hidden, topicViewerState: TopicViewerState = TopicViewerState.Hidden,
canAccessSpaceSettings: Boolean = true, 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.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncAction 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.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.ClickableLinkText
import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet
import io.element.android.libraries.designsystem.components.async.AsyncActionView 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.components.dialogs.ConfirmationDialog
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.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu 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.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
@ -98,6 +102,7 @@ fun SpaceView(
onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onViewMembersClick: () -> Unit, onViewMembersClick: () -> Unit,
onCreateRoomClick: () -> Unit,
onAddRoomClick: () -> Unit, onAddRoomClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit, acceptDeclineInviteView: @Composable () -> Unit,
@ -161,7 +166,8 @@ fun SpaceView(
}, },
onTopicClick = { topic -> onTopicClick = { topic ->
state.eventSink(SpaceEvents.ShowTopicViewer(topic)) state.eventSink(SpaceEvents.ShowTopicViewer(topic))
} },
onCreateRoomClick = onCreateRoomClick,
) )
JoinFailuresEffect( JoinFailuresEffect(
hasAnyFailure = state.hasAnyJoinFailures, hasAnyFailure = state.hasAnyJoinFailures,
@ -234,6 +240,7 @@ private fun SpaceViewContent(
state: SpaceState, state: SpaceState,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit, onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onCreateRoomClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier.fillMaxSize()) { LazyColumn(modifier.fillMaxSize()) {
@ -259,61 +266,90 @@ private fun SpaceViewContent(
} }
} }
} }
itemsIndexed(
items = state.children, if (state.children.isEmpty() && state.canEditSpaceGraph && !state.hasMoreToLoad) {
key = { _, spaceRoom -> spaceRoom.roomId } item {
) { index, spaceRoom -> EmptySpaceView(onCreateRoomClick = onCreateRoomClick)
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED }
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) } else {
val isSelected = state.isSelected(spaceRoom.roomId) itemsIndexed(
val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode items = state.children,
SpaceRoomItemView( key = { _, spaceRoom -> spaceRoom.roomId }
spaceRoom = spaceRoom, ) { index, spaceRoom ->
showUnreadIndicator = showUnreadIndicator, val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
hideAvatars = isInvitation && state.hideInvitesAvatar, val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
onClick = { val isSelected = state.isSelected(spaceRoom.roomId)
onRoomClick(spaceRoom) val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode
}, SpaceRoomItemView(
onLongClick = { spaceRoom = spaceRoom,
// TODO showUnreadIndicator = showUnreadIndicator,
}, hideAvatars = isInvitation && state.hideInvitesAvatar,
trailingAction = if (state.isManageMode) { onClick = {
{ onRoomClick(spaceRoom)
Checkbox( },
checked = isSelected, onLongClick = {
onCheckedChange = null, // 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) { if (index != state.children.lastIndex) {
state.eventSink(SpaceEvents.Join(spaceRoom)) HorizontalDivider()
} }
}, }
bottomAction = if (state.isManageMode) {
null if (state.hasMoreToLoad) {
} else { item {
spaceRoom.inviteButtons( LoadingMoreIndicator(eventSink = state.eventSink)
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)
} }
} }
} }
} }
@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 @Composable
private fun LoadingMoreIndicator( private fun LoadingMoreIndicator(
eventSink: (SpaceEvents) -> Unit, eventSink: (SpaceEvents) -> Unit,
@ -611,6 +647,7 @@ internal fun SpaceViewPreview(
acceptDeclineInviteView = {}, acceptDeclineInviteView = {},
onSettingsClick = {}, onSettingsClick = {},
onViewMembersClick = {}, onViewMembersClick = {},
onCreateRoomClick = {},
onAddRoomClick = {}, onAddRoomClick = {},
onBackClick = {}, 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.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat 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.api.SpaceEntryPoint
import io.element.android.features.space.impl.di.FakeSpaceFlowGraph import io.element.android.features.space.impl.di.FakeSpaceFlowGraph
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -43,7 +44,8 @@ class DefaultSpaceEntryPointTest {
spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) } spaceRoomListResult = { _: RoomId -> FakeSpaceRoomList(A_ROOM_ID) }
), ),
room = FakeJoinedRoom(), room = FakeJoinedRoom(),
graphFactory = FakeSpaceFlowGraph.Factory graphFactory = FakeSpaceFlowGraph.Factory,
createRoomEntryPoint = FakeCreateRoomEntryPoint(),
) )
} }
val callback = object : SpaceEntryPoint.Callback { 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.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 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.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom 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.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam 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.pressBack
import io.element.android.tests.testutils.pressBackKey import io.element.android.tests.testutils.pressBackKey
import org.junit.Rule import org.junit.Rule
@ -200,6 +202,22 @@ class SpaceViewTest {
rule.clickOn(CommonStrings.action_remove, inDialog = true) rule.clickOn(CommonStrings.action_remove, inDialog = true)
eventsRecorder.assertSingle(SpaceEvents.ConfirmRoomRemoval) 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( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(
@ -210,6 +228,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
onSettingsClick: () -> Unit = EnsureNeverCalled(), onSettingsClick: () -> Unit = EnsureNeverCalled(),
onViewMembersClick: () -> Unit = EnsureNeverCalled(), onViewMembersClick: () -> Unit = EnsureNeverCalled(),
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onAddRoomClick: () -> Unit = EnsureNeverCalled(), onAddRoomClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {}, acceptDeclineInviteView: @Composable () -> Unit = {},
) { ) {
@ -224,6 +243,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpace
onViewMembersClick = onViewMembersClick, onViewMembersClick = onViewMembersClick,
onAddRoomClick = onAddRoomClick, onAddRoomClick = onAddRoomClick,
acceptDeclineInviteView = acceptDeclineInviteView, acceptDeclineInviteView = acceptDeclineInviteView,
onCreateRoomClick = onCreateRoomClick,
) )
} }
} }

View file

@ -80,12 +80,14 @@ class StartChatFlowNode(
navigator.onRoomCreated(roomId.toRoomIdOrAlias(), emptyList()) navigator.onRoomCreated(roomId.toRoomIdOrAlias(), emptyList())
} }
} }
createRoomEntryPoint.createNode( createRoomEntryPoint
isSpace = false, .builder(
parentNode = this, parentNode = this,
buildContext = buildContext, buildContext = buildContext,
callback = callback, callback = callback,
) )
.setIsSpace(false)
.build()
} }
NavTarget.JoinByAddress -> { NavTarget.JoinByAddress -> {
createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator)) createNode<JoinRoomByAddressNode>(buildContext = buildContext, plugins = listOf(navigator))

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e59a9e2ae6ef36f28e61534b5639314cc840953df51bb1660e77e8d565865357 oid sha256:6444cece1e3f5dfc94591380837fb8aa8caecdc4a87881485278c036accf2007
size 32998 size 40411

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b854ce2b0618ebcbc88eff9952d6869bceeb8b43f9eccb8f8e5feef225e0a4c2 oid sha256:70e98028aafc62aeb86e69db82dce9b9bb0250d47339a81e8e832a0600d21217
size 33181 size 40588

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e1cf063ee5c9fbc50a53445050780ba239d4fb0fd1e9903a578eab8dd3bfc257 oid sha256:a6a950c626b57f2624ce8929f61dfa2fcdc25036e0b3ebb5ec87a00e7531aea8
size 33496 size 40898

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:525059397001897f705630b8ac5a661439a502f2e623fdca252f9e86f97133e4 oid sha256:778488da7192f2c3f98367d9d424783a42343a4bff8b7616f6175952526a8d30
size 58181 size 57766

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59af097a2152b235c7df83aa76eea880ceef65edebebecb86f00745ec39712f4 oid sha256:dbe04b9b4274183626f94d7014df9fc6462114226670161ffa3bb94b214b027e
size 35937 size 34283

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a5151bbd3effcd6a46724a0cfb13730e845a3b8ba489826146c76d0a797403e0 oid sha256:b86c4e7011f15079e01e7aa8e8c26b94cffb4ef451b3374d3834641336d3d036
size 36502 size 34853

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:47c8bdf4d153ecebe749ae3cacfe4c9a1c59ac836106fb0903dca80305aedac2 oid sha256:9b221b06e7533c21ed4393f388630c7119a1e7087b3a33de6c2cf498b01bbe65
size 32412 size 39544

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:33917297cb1c6d38e5b955757de50f4ff6b73844bb87e8e88d0885daa01b266b oid sha256:dd9b3d59af7fc710b7ef8f16ed961086693aa685de93658e2df85e3edbd7f787
size 32556 size 39689

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:930f763051533ea3aa4032e483e15e0c1ccb1d213d2e59bebe7f934517b500ba oid sha256:db27594444024da1818bbb089fac602b0937ad786072ad3542aa822e5a29c8c4
size 32868 size 40008

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dd673c2cf628285836848a732d3972a17421a2394464a3a77e7a49e4c5a862f1 oid sha256:21ec87536d892719f5343c0aacde2ed4be1a7d0987eff72675b03b4be5038f78
size 56636 size 56278

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b459fa8aa195dcf85995ac51c2f079e9d2505efede138bcef94c0a5049e1f271 oid sha256:8c01095702c3ab147349f9b60729b534ec1763bc5c04e43b6d571a52a115e10a
size 35266 size 33634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:36fd123fcff07169723857c99e4e375fe9bbb8193b2a6e97508343ea025d5787 oid sha256:ae1a096a6de24c2c6517455cd632291ad9d3d45e16df02fa75c33f278e44b26e
size 35782 size 34149