feature (space) : allow joining children from space screen

This commit is contained in:
ganfra 2025-09-26 11:25:08 +02:00
parent 7050076beb
commit 8f0841673c
8 changed files with 95 additions and 9 deletions

View file

@ -64,7 +64,7 @@ fun HomeSpacesView(
}, },
onLongClick = { onLongClick = {
// TODO // TODO
} },
) )
} }
} }

View file

@ -7,6 +7,9 @@
package io.element.android.features.space.impl package io.element.android.features.space.impl
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
sealed interface SpaceEvents { sealed interface SpaceEvents {
data object LoadMore : SpaceEvents data object LoadMore : SpaceEvents
data class Join(val spaceRoom: SpaceRoom): SpaceEvents
} }

View file

@ -9,18 +9,27 @@ package io.element.android.features.space.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient 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.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.join.JoinRoom
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.SpaceRoomList
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@ -31,14 +40,14 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@Inject @Inject class SpacePresenter(
class SpacePresenter(
@Assisted private val inputs: SpaceEntryPoint.Inputs, @Assisted private val inputs: SpaceEntryPoint.Inputs,
private val client: MatrixClient, private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> { ) : Presenter<SpaceState> {
@AssistedFactory @AssistedFactory fun interface Factory {
fun interface Factory {
fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter
} }
@ -54,7 +63,7 @@ class SpacePresenter(
seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } seenInvitesStore.seenRoomIds().map { it.toPersistentSet() }
}.collectAsState(persistentSetOf()) }.collectAsState(persistentSetOf())
val coroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList())
val hasMoreToLoad by remember { val hasMoreToLoad by remember {
spaceRoomList.paginationStatusFlow.mapState { status -> spaceRoomList.paginationStatusFlow.mapState { status ->
@ -66,10 +75,24 @@ class SpacePresenter(
}.collectAsState() }.collectAsState()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val joiningRooms = remember { mutableStateOf(emptySet<RoomId>()) }
LaunchedEffect(children, joiningRooms.value) {
val joinedChildren = children
.filter { it.state == CurrentUserMembership.JOINED }
.map { it.roomId }
.toSet()
joiningRooms.value.let { currentlyJoining ->
joiningRooms.value = currentlyJoining - joinedChildren
}
}
fun handleEvents(event: SpaceEvents) { fun handleEvents(event: SpaceEvents) {
when (event) { when (event) {
SpaceEvents.LoadMore -> coroutineScope.paginate() SpaceEvents.LoadMore -> localCoroutineScope.paginate()
is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joiningRooms)
}
} }
} }
return SpaceState( return SpaceState(
@ -78,10 +101,24 @@ class SpacePresenter(
seenSpaceInvites = seenSpaceInvites, seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
joiningRooms = joiningRooms.value.toPersistentSet(),
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.joinRoom(
spaceRoom: SpaceRoom, joiningRooms: MutableState<Set<RoomId>>
) = launch {
joiningRooms.value = joiningRooms.value + spaceRoom.roomId
joinRoom.invoke(
roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(),
serverNames = spaceRoom.via,
trigger = JoinedRoom.Trigger.SpaceHierarchy,
).onFailure {
joiningRooms.value = joiningRooms.value - spaceRoom.roomId
}
}
private fun CoroutineScope.paginate() = launch { private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate() spaceRoomList.paginate()
} }

View file

@ -18,5 +18,6 @@ data class SpaceState(
val seenSpaceInvites: ImmutableSet<RoomId>, val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean, val hasMoreToLoad: Boolean,
val joiningRooms: ImmutableSet<RoomId>,
val eventSink: (SpaceEvents) -> Unit val eventSink: (SpaceEvents) -> Unit
) )

View file

@ -47,6 +47,7 @@ fun aSpaceState(
), ),
children: List<SpaceRoom> = emptyList(), children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(), seenSpaceInvites: Set<RoomId> = emptySet(),
joiningRooms: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false, hasMoreToLoad: Boolean = false,
) = SpaceState( ) = SpaceState(
@ -55,6 +56,7 @@ fun aSpaceState(
seenSpaceInvites = seenSpaceInvites.toImmutableSet(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
joiningRooms = joiningRooms.toImmutableSet(),
eventSink = {}) eventSink = {})
private fun aListOfSpaceRooms(): List<SpaceRoom> { private fun aListOfSpaceRooms(): List<SpaceRoom> {

View file

@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@ -35,9 +37,11 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
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.ButtonSize
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.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.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
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
@ -96,6 +100,7 @@ private fun SpaceViewContent(
state.children.forEach { spaceRoom -> state.children.forEach { spaceRoom ->
item { item {
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = spaceRoom.roomId in state.joiningRooms
SpaceRoomItemView( SpaceRoomItemView(
spaceRoom = spaceRoom, spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
@ -105,6 +110,9 @@ private fun SpaceViewContent(
}, },
onLongClick = { onLongClick = {
// TODO // TODO
},
trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
} }
) )
} }
@ -191,6 +199,27 @@ private fun SpaceAvatarAndNameRow(
} }
} }
private fun SpaceRoom.trailingAction(
isCurrentlyJoining: Boolean,
onClick: () -> Unit
): @Composable (() -> Unit)? {
return when (state) {
null, CurrentUserMembership.LEFT -> {
@Composable {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionAccent) {
TextButton(
text = stringResource(CommonStrings.action_join),
onClick = onClick,
size = ButtonSize.LargeLowPadding,
showProgress = isCurrentlyJoining,
)
}
}
}
else -> null
}
}
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun SpaceViewPreview( internal fun SpaceViewPreview(

View file

@ -15,10 +15,12 @@ import io.element.android.features.space.api.SpaceEntryPoint
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.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
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 kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -27,7 +29,7 @@ class DefaultSpaceEntryPointTest {
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test @Test
fun `test node builder`() { fun `test node builder`() = runTest {
val entryPoint = DefaultSpaceEntryPoint() val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID) val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
@ -44,6 +46,10 @@ class DefaultSpaceEntryPointTest {
) )
), ),
seenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore = InMemorySeenInvitesStore(),
joinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) },
),
sessionCoroutineScope = backgroundScope,
) )
}, },
) )

View file

@ -14,15 +14,18 @@ import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceRoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -153,15 +156,20 @@ class SpacePresenterTest {
} }
} }
private fun createSpacePresenter( private fun TestScope.createSpacePresenter(
inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID), inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID),
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) },
),
): SpacePresenter { ): SpacePresenter {
return SpacePresenter( return SpacePresenter(
inputs = inputs, inputs = inputs,
client = client, client = client,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom,
sessionCoroutineScope = backgroundScope,
) )
} }
} }