Merge pull request #5431 from element-hq/feature/fga/space_list_join_action

Feature : space list join action
This commit is contained in:
Benoit Marty
2025-10-01 14:53:30 +02:00
committed by GitHub
42 changed files with 829 additions and 80 deletions

View File

@@ -16,14 +16,12 @@ import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.features.home.impl.model.anInviteSender
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -76,16 +74,6 @@ internal fun aLeaveRoomState(
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink
}
internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf(
aRoomListRoomSummary(

View File

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

View File

@@ -24,6 +24,7 @@ import io.element.android.features.home.impl.search.aRoomListSearchState
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState

View File

@@ -0,0 +1,21 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.api.acceptdecline
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {},
) = AcceptDeclineInviteState(
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)

View File

@@ -9,9 +9,9 @@ package io.element.android.features.invite.impl.acceptdecline
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.impl.AcceptInvite
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
@@ -51,13 +51,3 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
),
)
}
private fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)

View File

@@ -9,8 +9,8 @@ package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -219,16 +219,6 @@ fun aJoinRoomState(
eventSink = eventSink
)
internal fun anAcceptDeclineInviteState(
acceptAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
declineAction: AsyncAction<RoomId> = AsyncAction.Uninitialized,
eventSink: (AcceptDeclineInviteEvents) -> Unit = {}
) = AcceptDeclineInviteState(
acceptAction = acceptAction,
declineAction = declineAction,
eventSink = eventSink,
)
internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"),
displayName: String = "Bob",

View File

@@ -13,6 +13,7 @@ import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom

View File

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

View File

@@ -18,6 +18,7 @@ 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.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
@@ -36,11 +37,13 @@ class SpaceNode(
private val presenter: SpacePresenter,
private val matrixClient: MatrixClient,
private val spaceRoomList: SpaceRoomList,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, viaParameters: List<String>)
fun onLeaveSpace()
}
private val callback = plugins.filterIsInstance<Callback>().single()
private fun onShareRoom(context: Context) = lifecycleScope.launch {
@@ -76,6 +79,18 @@ class SpaceNode(
onShareSpace = {
onShareRoom(context)
},
acceptDeclineInviteView = {
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = { roomId ->
callback.onOpenRoom(roomId, emptyList())
},
onDeclineInviteSuccess = { roomId ->
// No action needed
},
modifier = Modifier
)
},
modifier = modifier
)
}

View File

@@ -11,17 +11,30 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Inject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
import io.element.android.libraries.architecture.AsyncAction
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
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
@@ -33,6 +46,9 @@ class SpacePresenter(
private val spaceRoomList: SpaceRoomList,
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> {
@Composable
override fun present(): SpaceState {
@@ -44,7 +60,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 ->
@@ -56,10 +72,40 @@ class SpacePresenter(
}.collectAsState()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap<RoomId, AsyncAction<Unit>>()) }
LaunchedEffect(children) {
// Remove joined children from the join actions
val joinedChildren = children
.filter { it.state == CurrentUserMembership.JOINED }
.map { it.roomId }
setJoinActions(joinActions - joinedChildren)
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
fun handleEvents(event: SpaceEvents) {
when (event) {
SpaceEvents.LoadMore -> coroutineScope.paginate()
SpaceEvents.LoadMore -> localCoroutineScope.paginate()
is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
}
SpaceEvents.ClearFailures -> {
val failedActions = joinActions
.filterValues { it is AsyncAction.Failure }
.mapValues { AsyncAction.Uninitialized }
setJoinActions(joinActions + failedActions)
}
is SpaceEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(event.spaceRoom.toInviteData())
)
}
is SpaceEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(invite = event.spaceRoom.toInviteData(), shouldConfirm = true, blockUser = false)
)
}
}
}
return SpaceState(
@@ -68,10 +114,27 @@ class SpacePresenter(
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toPersistentMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.joinRoom(
spaceRoom: SpaceRoom,
joinActions: Map<RoomId, AsyncAction<Unit>>,
setJoinActions: (Map<RoomId, AsyncAction<Unit>>) -> Unit
) = launch {
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Loading))
joinRoom.invoke(
roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(),
serverNames = spaceRoom.via,
trigger = JoinedRoom.Trigger.SpaceHierarchy,
).onFailure {
setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)))
}
}
private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate()
}

View File

@@ -7,9 +7,12 @@
package io.element.android.features.space.impl.root
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
data class SpaceState(
@@ -18,5 +21,12 @@ data class SpaceState(
val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean,
val joinActions: ImmutableMap<RoomId, AsyncAction<Unit>>,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (SpaceEvents) -> Unit
)
) {
fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading
val hasAnyFailure: Boolean = joinActions.values.any {
it is AsyncAction.Failure
}
}

View File

@@ -8,10 +8,15 @@
package io.element.android.features.space.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
@@ -33,8 +38,9 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
),
aSpaceState(
hasMoreToLoad = false,
children = aListOfSpaceRooms()
),
children = aListOfSpaceRooms(),
joiningRooms = setOf(RoomId("!spaceId0:example.com")),
)
// Add other states here
)
}
@@ -48,21 +54,36 @@ fun aSpaceState(
),
children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(),
joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState(
currentSpace = parentSpace,
children = children.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad,
eventSink = {}
joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
)
private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf(
aSpaceRoom(roomId = RoomId("!spaceId0:example.com")),
aSpaceRoom(roomId = RoomId("!spaceId1:example.com")),
aSpaceRoom(roomId = RoomId("!spaceId2:example.com")),
aSpaceRoom(
roomId = RoomId("!spaceId0:example.com"),
state = null,
),
aSpaceRoom(
roomId = RoomId("!spaceId1:example.com"),
state = CurrentUserMembership.JOINED,
),
aSpaceRoom(
roomId = RoomId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED,
),
)
}

View File

@@ -32,6 +32,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -49,26 +53,29 @@ import io.element.android.libraries.designsystem.theme.components.Text
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
import io.element.android.libraries.matrix.ui.components.JoinButton
import io.element.android.libraries.matrix.ui.components.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@Composable
fun SpaceView(
state: SpaceState,
onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onShareSpace: () -> Unit,
onLeaveSpaceClick: () -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
SpaceViewTopBar(
state = state,
currentSpace = state.currentSpace,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
@@ -82,11 +89,37 @@ fun SpaceView(
state = state,
onRoomClick = onRoomClick
)
JoinRoomFailureEffect(
hasAnyFailure = state.hasAnyFailure,
eventSink = state.eventSink
)
acceptDeclineInviteView()
}
},
)
}
@Composable
private fun JoinRoomFailureEffect(
hasAnyFailure: Boolean,
eventSink: (SpaceEvents) -> Unit,
) {
val asyncIndicatorState = rememberAsyncIndicatorState()
val updatedEventSink by rememberUpdatedState(eventSink)
AsyncIndicatorHost(modifier = Modifier, asyncIndicatorState)
LaunchedEffect(hasAnyFailure) {
if (hasAnyFailure) {
asyncIndicatorState.enqueue {
AsyncIndicator.Failure(text = stringResource(CommonStrings.common_something_went_wrong))
}
delay(AsyncIndicator.DURATION_SHORT)
updatedEventSink(SpaceEvents.ClearFailures)
} else {
asyncIndicatorState.clear()
}
}
}
@Composable
private fun SpaceViewContent(
state: SpaceState,
@@ -111,6 +144,7 @@ private fun SpaceViewContent(
state.children.forEach { spaceRoom ->
item {
val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED
val isCurrentlyJoining = state.isJoining(spaceRoom.roomId)
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
@@ -120,7 +154,18 @@ private fun SpaceViewContent(
},
onLongClick = {
// TODO
}
},
trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom))
},
bottomAction = spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
)
}
}
@@ -155,13 +200,12 @@ private fun LoadingMoreIndicator(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SpaceViewTopBar(
state: SpaceState,
currentSpace: SpaceRoom?,
onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier,
) {
val currentSpace = state.currentSpace
TopAppBar(
modifier = modifier,
navigationIcon = {
@@ -254,6 +298,40 @@ private fun SpaceAvatarAndNameRow(
}
}
private fun SpaceRoom.trailingAction(
isCurrentlyJoining: Boolean,
onClick: () -> Unit
): @Composable (() -> Unit)? {
return when (state) {
null, CurrentUserMembership.LEFT -> {
{
JoinButton(
showProgress = isCurrentlyJoining,
onClick = onClick,
)
}
}
else -> null
}
}
private fun SpaceRoom.inviteButtons(
onAcceptClick: () -> Unit,
onDeclineClick: () -> Unit,
): @Composable (() -> Unit)? {
return when (state) {
CurrentUserMembership.INVITED -> {
@Composable {
InviteButtonsRowMolecule(
onAcceptClick = onAcceptClick,
onDeclineClick = onDeclineClick,
)
}
}
else -> null
}
}
@PreviewsDayNight
@Composable
internal fun SpaceViewPreview(
@@ -261,9 +339,10 @@ internal fun SpaceViewPreview(
) = ElementPreview {
SpaceView(
state = state,
onBackClick = {},
onLeaveSpaceClick = {},
onRoomClick = {},
onShareSpace = {},
onLeaveSpaceClick = {},
acceptDeclineInviteView = {},
onBackClick = {},
)
}

View File

@@ -11,18 +11,37 @@ package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
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.SpaceRoomList
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.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.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.EventsRecorder
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.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
class SpacePresenterTest {
@Test
@@ -39,6 +58,8 @@ class SpacePresenterTest {
assertThat(state.seenSpaceInvites).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.hasMoreToLoad).isTrue()
assertThat(state.joinActions).isEmpty()
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
advanceUntilIdle()
paginateResult.assertions().isCalledOnce()
}
@@ -117,15 +138,184 @@ class SpacePresenterTest {
}
}
private fun createSpacePresenter(
@Test
fun `present - join a room success`() = runTest {
val joinRoom = lambdaRecorder<RoomIdOrAlias, List<String>, AnalyticsJoinedRoom.Trigger, Result<Unit>> { _, _, _ ->
Result.success(Unit)
}
val serverNames = listOf("via1", "via2")
val aNotJoinedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
via = serverNames,
state = null,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
joinRoom = FakeJoinRoom(
lambda = joinRoom,
),
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
state.eventSink(SpaceEvents.Join(aNotJoinedRoom))
val joiningState = awaitItem()
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
// Let the joinRoom call complete
advanceUntilIdle()
runCurrent()
// The room is joined
fakeSpaceRoomList.emitSpaceRooms(
listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom.copy(state = CurrentUserMembership.JOINED),
)
)
skipItems(1)
val joinedState = awaitItem()
// Joined room is removed from the join actions
assertThat(joinedState.joinActions).doesNotContainKey(A_ROOM_ID_2)
joinRoom.assertions().isCalledOnce().with(
value(A_ROOM_ID_2.toRoomIdOrAlias()),
value(serverNames),
value(AnalyticsJoinedRoom.Trigger.SpaceHierarchy),
)
}
}
@Test
fun `present - join a room failure`() = runTest {
val aNotJoinedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
state = null,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
aNotJoinedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
joinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.failure(AN_EXCEPTION) },
),
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
state.eventSink(SpaceEvents.Join(aNotJoinedRoom))
val joiningState = awaitItem()
assertThat(joiningState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Loading)
val errorState = awaitItem()
// Joined room is removed from the join actions
assertThat(errorState.joinActions[A_ROOM_ID_2]!!.isFailure()).isTrue()
// Clear error
errorState.eventSink(SpaceEvents.ClearFailures)
val clearedState = awaitItem()
assertThat(clearedState.joinActions[A_ROOM_ID_2]).isEqualTo(AsyncAction.Uninitialized)
}
}
@Test
fun `present - accept invite is transmitted to acceptDeclineInviteState`() {
`invite action is transmitted to acceptDeclineInviteState`(
acceptInvite = true,
)
}
@Test
fun `present - decline invite is transmitted to acceptDeclineInviteState`() {
`invite action is transmitted to acceptDeclineInviteState`(
acceptInvite = false,
)
}
private fun `invite action is transmitted to acceptDeclineInviteState`(
acceptInvite: Boolean,
) = runTest {
val eventRecorder = EventsRecorder<AcceptDeclineInviteEvents>()
val anInvitedRoom = aSpaceRoom(
roomId = A_ROOM_ID_2,
state = CurrentUserMembership.INVITED,
)
val fakeSpaceRoomList = FakeSpaceRoomList(
initialSpaceRoomsValue = listOf(
aSpaceRoom(
roomId = A_ROOM_ID,
state = CurrentUserMembership.JOINED,
),
anInvitedRoom,
),
paginateResult = { Result.success(Unit) },
)
val presenter = createSpacePresenter(
spaceRoomList = fakeSpaceRoomList,
acceptDeclineInvitePresenter = {
anAcceptDeclineInviteState(
eventSink = eventRecorder,
)
},
)
presenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.joinActions[A_ROOM_ID_2]).isNull()
if (acceptInvite) {
state.eventSink(SpaceEvents.AcceptInvite(anInvitedRoom))
eventRecorder.assertSingle(
AcceptDeclineInviteEvents.AcceptInvite(
invite = anInvitedRoom.toInviteData(),
)
)
} else {
state.eventSink(SpaceEvents.DeclineInvite(anInvitedRoom))
eventRecorder.assertSingle(
AcceptDeclineInviteEvents.DeclineInvite(
invite = anInvitedRoom.toInviteData(),
shouldConfirm = true,
blockUser = false,
)
)
}
}
}
private fun TestScope.createSpacePresenter(
client: MatrixClient = FakeMatrixClient(),
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) },
),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter {
return SpacePresenter(
client = client,
spaceRoomList = spaceRoomList,
seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
sessionCoroutineScope = backgroundScope,
)
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
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_ROOM_ID_3
import org.junit.Test
class SpaceStateTest {
@Test
fun `test default state`() {
val state = aSpaceState()
assertThat(state.hasAnyFailure).isFalse()
assertThat(state.isJoining(A_ROOM_ID)).isFalse()
}
@Test
fun `test has failure`() {
val state = aSpaceState(
joinActions = mapOf(
A_ROOM_ID to AsyncAction.Uninitialized,
A_ROOM_ID_2 to AsyncAction.Failure(AN_EXCEPTION),
A_ROOM_ID_3 to AsyncAction.Success(Unit),
)
)
assertThat(state.hasAnyFailure).isTrue()
}
@Test
fun `test isJoining`() {
val state = aSpaceState(
joinActions = mapOf(
A_ROOM_ID to AsyncAction.Loading,
)
)
assertThat(state.isJoining(A_ROOM_ID)).isTrue()
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.root
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
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.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
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.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SpaceViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<SpaceEvents>(expectEvents = false)
ensureCalledOnce {
rule.setSpaceView(
aSpaceState(
eventSink = eventsRecorder,
),
onBackClick = it,
)
rule.pressBack()
}
}
@Test
fun `clicking on a room name invokes the expected callback`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, name = A_ROOM_NAME)
val eventsRecorder = EventsRecorder<SpaceEvents>(expectEvents = false)
ensureCalledOnceWithParam(aSpaceRoom) {
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
onRoomClick = it,
)
rule.onNodeWithText(A_ROOM_NAME).performClick()
}
}
@Test
fun `clicking on Join room emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = null)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_join)
eventsRecorder.assertSingle(SpaceEvents.Join(aSpaceRoom))
}
@Test
fun `clicking on accept invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(SpaceEvents.AcceptInvite(aSpaceRoom))
}
@Test
fun `clicking on decline invite emits the expected Event`() {
val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, state = CurrentUserMembership.INVITED)
val eventsRecorder = EventsRecorder<SpaceEvents>()
rule.setSpaceView(
aSpaceState(
children = listOf(aSpaceRoom),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(SpaceEvents.DeclineInvite(aSpaceRoom))
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSpaceView(
state: SpaceState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomClick: (SpaceRoom) -> Unit = EnsureNeverCalledWithParam(),
onShareSpace: () -> Unit = EnsureNeverCalled(),
onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(),
acceptDeclineInviteView: @Composable () -> Unit = {},
) {
setContent {
SpaceView(
state = state,
onBackClick = onBackClick,
onRoomClick = onRoomClick,
onShareSpace = onShareSpace,
onLeaveSpaceClick = onLeaveSpaceClick,
acceptDeclineInviteView = acceptDeclineInviteView,
)
}
}

View File

@@ -37,6 +37,7 @@ dependencies {
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
implementation(libs.jsoup)
implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun JoinButton(
showProgress: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textActionAccent) {
TextButton(
modifier = modifier,
text = stringResource(CommonStrings.action_join),
onClick = onClick,
size = ButtonSize.LargeLowPadding,
showProgress = showProgress,
)
}
}

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@@ -41,6 +42,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.unreadIndicator
@@ -59,6 +62,8 @@ fun SpaceRoomItemView(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null,
bottomAction: @Composable (() -> Unit)? = null,
) {
SpaceRoomItemScaffold(
modifier = modifier,
@@ -67,6 +72,7 @@ fun SpaceRoomItemView(
hideAvatars = hideAvatars,
onClick = onClick,
onLongClick = onLongClick,
trailingAction = trailingAction,
) {
NameAndIndicatorRow(
isSpace = spaceRoom.isSpace,
@@ -79,22 +85,22 @@ fun SpaceRoomItemView(
subtitle = spaceRoom.subtitle()
)
Spacer(modifier = Modifier.height(1.dp))
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = spaceRoom.info(),
fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null },
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (spaceRoom.state == CurrentUserMembership.INVITED) {
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRowMolecule(
onAcceptClick = {},
onDeclineClick = {},
val info = spaceRoom.info()
if (info.isNotBlank()) {
Text(
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyMdRegular,
text = info,
fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null },
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
bottomAction()
}
}
}
@@ -166,7 +172,8 @@ private fun SpaceRoomItemScaffold(
onLongClick: () -> Unit,
hideAvatars: Boolean,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
trailingAction: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val clickModifier = Modifier
.combinedClickable(
@@ -194,6 +201,10 @@ private fun SpaceRoomItemScaffold(
modifier = Modifier.weight(1f),
content = content,
)
if (trailingAction != null) {
Spacer(modifier = Modifier.width(16.dp))
trailingAction()
}
}
}
@@ -233,3 +244,32 @@ private fun SpaceRoom.visibilityIcon(): ImageVector? {
CompoundIcons.LockSolid()
}
}
@Composable
@PreviewsDayNight
internal fun SpaceRoomItemViewPreview(@PreviewParameter(SpaceRoomProvider::class) spaceRoom: SpaceRoom) = ElementPreview {
SpaceRoomItemView(
spaceRoom = spaceRoom,
showUnreadIndicator = spaceRoom.state == CurrentUserMembership.INVITED,
hideAvatars = false,
onClick = {},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
bottomAction = if (spaceRoom.state == CurrentUserMembership.INVITED) {
{ InviteButtonsRowMolecule({}, {}) }
} else {
null
},
trailingAction = when (spaceRoom.state) {
null, CurrentUserMembership.LEFT -> {
{
JoinButton(
showProgress = spaceRoom.state == CurrentUserMembership.LEFT,
onClick = { },
)
}
}
else -> null
}
)
}

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf(
aSpaceRoom(
roomType = RoomType.Room,
name = "Room name with topic",
topic = "Room topic that is quite long and might be truncated"
),
aSpaceRoom(
roomType = RoomType.Room,
name = "Room name no topic",
state = CurrentUserMembership.LEFT,
),
aSpaceRoom(
roomType = RoomType.Room,
name = "Room name with topic",
topic = "Room topic that is quite long and might be truncated",
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
roomType = RoomType.Room,
name = "Room name no topic",
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
roomId = RoomId("!spaceId0:example.com"),
),
aSpaceRoom(
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
roomId = RoomId("!spaceId1:example.com"),
state = CurrentUserMembership.LEFT,
),
aSpaceRoom(
name = null,
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
roomId = RoomId("!spaceId2:example.com"),
state = CurrentUserMembership.INVITED,
),
aSpaceRoom(
name = null,
numJoinedMembers = 5,
childrenCount = 10,
worldReadable = true,
avatarUrl = "anUrl",
roomId = RoomId("!spaceId3:example.com"),
state = CurrentUserMembership.INVITED,
),
)
}