diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt index 8b07b9f526..36e0bbc56a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesView.kt @@ -64,7 +64,7 @@ fun HomeSpacesView( }, onLongClick = { // TODO - } + }, ) } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt index 848dac3ebc..3314d5c39c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceEvents.kt @@ -7,6 +7,9 @@ package io.element.android.features.space.impl +import io.element.android.libraries.matrix.api.spaces.SpaceRoom + sealed interface SpaceEvents { data object LoadMore : SpaceEvents + data class Join(val spaceRoom: SpaceRoom): SpaceEvents } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt index f5bfdcc840..7ec8e4c226 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpacePresenter.kt @@ -9,18 +9,27 @@ package io.element.android.features.space.impl 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory 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.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.Presenter 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.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.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.persistentSetOf @@ -31,14 +40,14 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.jvm.optionals.getOrNull -@Inject -class SpacePresenter( +@Inject class SpacePresenter( @Assisted private val inputs: SpaceEntryPoint.Inputs, private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, + private val joinRoom: JoinRoom, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, ) : Presenter { - @AssistedFactory - fun interface Factory { + @AssistedFactory fun interface Factory { fun create(inputs: SpaceEntryPoint.Inputs): SpacePresenter } @@ -54,7 +63,7 @@ class SpacePresenter( seenInvitesStore.seenRoomIds().map { it.toPersistentSet() } }.collectAsState(persistentSetOf()) - val coroutineScope = rememberCoroutineScope() + val localCoroutineScope = rememberCoroutineScope() val children by spaceRoomList.spaceRoomsFlow.collectAsState(emptyList()) val hasMoreToLoad by remember { spaceRoomList.paginationStatusFlow.mapState { status -> @@ -66,10 +75,24 @@ class SpacePresenter( }.collectAsState() val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() + val joiningRooms = remember { mutableStateOf(emptySet()) } + + 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) { when (event) { - SpaceEvents.LoadMore -> coroutineScope.paginate() + SpaceEvents.LoadMore -> localCoroutineScope.paginate() + is SpaceEvents.Join -> { + sessionCoroutineScope.joinRoom(event.spaceRoom, joiningRooms) + } } } return SpaceState( @@ -78,10 +101,24 @@ class SpacePresenter( seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, hasMoreToLoad = hasMoreToLoad, + joiningRooms = joiningRooms.value.toPersistentSet(), eventSink = ::handleEvents, ) } + private fun CoroutineScope.joinRoom( + spaceRoom: SpaceRoom, joiningRooms: MutableState> + ) = 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 { spaceRoomList.paginate() } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt index ad822283ca..f3f7a2194e 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceState.kt @@ -18,5 +18,6 @@ data class SpaceState( val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, val hasMoreToLoad: Boolean, + val joiningRooms: ImmutableSet, val eventSink: (SpaceEvents) -> Unit ) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt index 07ebaa0a90..41a1e80f94 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceStateProvider.kt @@ -47,6 +47,7 @@ fun aSpaceState( ), children: List = emptyList(), seenSpaceInvites: Set = emptySet(), + joiningRooms: Set = emptySet(), hideInvitesAvatar: Boolean = false, hasMoreToLoad: Boolean = false, ) = SpaceState( @@ -55,6 +56,7 @@ fun aSpaceState( seenSpaceInvites = seenSpaceInvites.toImmutableSet(), hideInvitesAvatar = hideInvitesAvatar, hasMoreToLoad = hasMoreToLoad, + joiningRooms = joiningRooms.toImmutableSet(), eventSink = {}) private fun aListOfSpaceRooms(): List { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt index f1f8356701..7ca13504e0 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceView.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.preview.ElementPreview 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.Scaffold 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.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom @@ -96,6 +100,7 @@ private fun SpaceViewContent( state.children.forEach { spaceRoom -> item { val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED + val isCurrentlyJoining = spaceRoom.roomId in state.joiningRooms SpaceRoomItemView( spaceRoom = spaceRoom, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, @@ -105,6 +110,9 @@ private fun SpaceViewContent( }, onLongClick = { // 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 @Composable internal fun SpaceViewPreview( diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index cafc825f6a..748533098e 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -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.test.A_ROOM_ID 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.FakeSpaceService import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.node.TestParentNode +import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -27,7 +29,7 @@ class DefaultSpaceEntryPointTest { val instantTaskExecutorRule = InstantTaskExecutorRule() @Test - fun `test node builder`() { + fun `test node builder`() = runTest { val entryPoint = DefaultSpaceEntryPoint() val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID) val parentNode = TestParentNode.create { buildContext, plugins -> @@ -44,6 +46,10 @@ class DefaultSpaceEntryPointTest { ) ), seenInvitesStore = InMemorySeenInvitesStore(), + joinRoom = FakeJoinRoom( + lambda = { _, _, _ -> Result.success(Unit) }, + ), + sessionCoroutineScope = backgroundScope, ) }, ) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt index 0bcd1303ae..461c23cb25 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/SpacePresenterTest.kt @@ -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.space.api.SpaceEntryPoint 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.test.A_ROOM_ID 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.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest 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), client: MatrixClient = FakeMatrixClient(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + joinRoom: JoinRoom = FakeJoinRoom( + lambda = { _, _, _ -> Result.success(Unit) }, + ), ): SpacePresenter { return SpacePresenter( inputs = inputs, client = client, seenInvitesStore = seenInvitesStore, + joinRoom = joinRoom, + sessionCoroutineScope = backgroundScope, ) } }