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
commit 9ba549f08e
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.model.anInviteSender
import io.element.android.features.home.impl.search.RoomListSearchState import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.search.aRoomListSearchState 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.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.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState 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.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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 io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@ -76,16 +74,6 @@ internal fun aLeaveRoomState(
override val eventSink: (LeaveRoomEvent) -> Unit = eventSink 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> { internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
return persistentListOf( return persistentListOf(
aRoomListRoomSummary( aRoomListRoomSummary(

View file

@ -64,7 +64,7 @@ fun HomeSpacesView(
}, },
onLongClick = { onLongClick = {
// TODO // 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.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents 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.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.InviteData 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.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.ConfirmingDeclineInvite 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.features.invite.impl.AcceptInvite
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.InviteData 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.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.AvatarSize
@ -219,16 +219,6 @@ fun aJoinRoomState(
eventSink = eventSink 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( internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"), userId: UserId = UserId("@bob:domain"),
displayName: String = "Bob", 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.SeenInvitesStore
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents 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.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.api.toInviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom import io.element.android.features.joinroom.impl.di.CancelKnockRoom

View file

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

View file

@ -11,17 +11,30 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
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.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.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.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
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.collections.immutable.toPersistentSet import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -33,6 +46,9 @@ class SpacePresenter(
private val spaceRoomList: SpaceRoomList, private val spaceRoomList: SpaceRoomList,
private val client: MatrixClient, private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> { ) : Presenter<SpaceState> {
@Composable @Composable
override fun present(): SpaceState { override fun present(): SpaceState {
@ -44,7 +60,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 ->
@ -56,10 +72,40 @@ class SpacePresenter(
}.collectAsState() }.collectAsState()
val currentSpace by spaceRoomList.currentSpaceFlow.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) { fun handleEvents(event: SpaceEvents) {
when (event) { 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( return SpaceState(
@ -68,10 +114,27 @@ class SpacePresenter(
seenSpaceInvites = seenSpaceInvites, seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.toPersistentMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents, 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 { private fun CoroutineScope.paginate() = launch {
spaceRoomList.paginate() spaceRoomList.paginate()
} }

View file

@ -7,9 +7,12 @@
package io.element.android.features.space.impl.root 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.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
data class SpaceState( data class SpaceState(
@ -18,5 +21,12 @@ data class SpaceState(
val seenSpaceInvites: ImmutableSet<RoomId>, val seenSpaceInvites: ImmutableSet<RoomId>,
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean, val hasMoreToLoad: Boolean,
val joinActions: ImmutableMap<RoomId, AsyncAction<Unit>>,
val acceptDeclineInviteState: AcceptDeclineInviteState,
val eventSink: (SpaceEvents) -> Unit 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 package io.element.android.features.space.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.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.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
open class SpaceStateProvider : PreviewParameterProvider<SpaceState> { open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
@ -33,8 +38,9 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
), ),
aSpaceState( aSpaceState(
hasMoreToLoad = false, hasMoreToLoad = false,
children = aListOfSpaceRooms() children = aListOfSpaceRooms(),
), joiningRooms = setOf(RoomId("!spaceId0:example.com")),
)
// Add other states here // Add other states here
) )
} }
@ -48,21 +54,36 @@ fun aSpaceState(
), ),
children: List<SpaceRoom> = emptyList(), children: List<SpaceRoom> = emptyList(),
seenSpaceInvites: Set<RoomId> = emptySet(), seenSpaceInvites: Set<RoomId> = emptySet(),
joiningRooms: Set<RoomId> = emptySet(),
joinActions: Map<RoomId, AsyncAction<Unit>> = joiningRooms.associateWith { AsyncAction.Loading },
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false, hasMoreToLoad: Boolean = false,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (SpaceEvents) -> Unit = { },
) = SpaceState( ) = SpaceState(
currentSpace = parentSpace, currentSpace = parentSpace,
children = children.toImmutableList(), children = children.toImmutableList(),
seenSpaceInvites = seenSpaceInvites.toImmutableSet(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(),
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
eventSink = {} joinActions = joinActions.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
) )
private fun aListOfSpaceRooms(): List<SpaceRoom> { private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf( return listOf(
aSpaceRoom(roomId = RoomId("!spaceId0:example.com")), aSpaceRoom(
aSpaceRoom(roomId = RoomId("!spaceId1:example.com")), roomId = RoomId("!spaceId0:example.com"),
aSpaceRoom(roomId = RoomId("!spaceId2: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 androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme 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.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.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.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.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
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.SpaceHeaderView
import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@Composable @Composable
fun SpaceView( fun SpaceView(
state: SpaceState, state: SpaceState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit, onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
onLeaveSpaceClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
SpaceViewTopBar( SpaceViewTopBar(
state = state, currentSpace = state.currentSpace,
onBackClick = onBackClick, onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick, onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace, onShareSpace = onShareSpace,
@ -82,11 +89,37 @@ fun SpaceView(
state = state, state = state,
onRoomClick = onRoomClick 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 @Composable
private fun SpaceViewContent( private fun SpaceViewContent(
state: SpaceState, state: SpaceState,
@ -111,6 +144,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 = state.isJoining(spaceRoom.roomId)
SpaceRoomItemView( SpaceRoomItemView(
spaceRoom = spaceRoom, spaceRoom = spaceRoom,
showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites,
@ -120,7 +154,18 @@ private fun SpaceViewContent(
}, },
onLongClick = { onLongClick = {
// TODO // 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SpaceViewTopBar( private fun SpaceViewTopBar(
state: SpaceState, currentSpace: SpaceRoom?,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit, @Suppress("unused") onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentSpace = state.currentSpace
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
navigationIcon = { 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 @PreviewsDayNight
@Composable @Composable
internal fun SpaceViewPreview( internal fun SpaceViewPreview(
@ -261,9 +339,10 @@ internal fun SpaceViewPreview(
) = ElementPreview { ) = ElementPreview {
SpaceView( SpaceView(
state = state, state = state,
onBackClick = {},
onLeaveSpaceClick = {},
onRoomClick = {}, onRoomClick = {},
onShareSpace = {}, 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 com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore 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.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.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.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.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.previewutils.room.aSpaceRoom 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.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
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.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import im.vector.app.features.analytics.plan.JoinedRoom as AnalyticsJoinedRoom
class SpacePresenterTest { class SpacePresenterTest {
@Test @Test
@ -39,6 +58,8 @@ class SpacePresenterTest {
assertThat(state.seenSpaceInvites).isEmpty() assertThat(state.seenSpaceInvites).isEmpty()
assertThat(state.hideInvitesAvatar).isFalse() assertThat(state.hideInvitesAvatar).isFalse()
assertThat(state.hasMoreToLoad).isTrue() assertThat(state.hasMoreToLoad).isTrue()
assertThat(state.joinActions).isEmpty()
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
advanceUntilIdle() advanceUntilIdle()
paginateResult.assertions().isCalledOnce() 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(), client: MatrixClient = FakeMatrixClient(),
spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), spaceRoomList: SpaceRoomList = FakeSpaceRoomList(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) },
),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter { ): SpacePresenter {
return SpacePresenter( return SpacePresenter(
client = client, client = client,
spaceRoomList = spaceRoomList, spaceRoomList = spaceRoomList,
seenInvitesStore = seenInvitesStore, 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.gif)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.jsoup) implementation(libs.jsoup)
implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true) testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test) 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.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons 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.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction 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.Icon
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.unreadIndicator import io.element.android.libraries.designsystem.theme.unreadIndicator
@ -59,6 +62,8 @@ fun SpaceRoomItemView(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null,
bottomAction: @Composable (() -> Unit)? = null,
) { ) {
SpaceRoomItemScaffold( SpaceRoomItemScaffold(
modifier = modifier, modifier = modifier,
@ -67,6 +72,7 @@ fun SpaceRoomItemView(
hideAvatars = hideAvatars, hideAvatars = hideAvatars,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
trailingAction = trailingAction,
) { ) {
NameAndIndicatorRow( NameAndIndicatorRow(
isSpace = spaceRoom.isSpace, isSpace = spaceRoom.isSpace,
@ -79,22 +85,22 @@ fun SpaceRoomItemView(
subtitle = spaceRoom.subtitle() subtitle = spaceRoom.subtitle()
) )
Spacer(modifier = Modifier.height(1.dp)) Spacer(modifier = Modifier.height(1.dp))
Text( val info = spaceRoom.info()
modifier = Modifier.weight(1f), if (info.isNotBlank()) {
style = ElementTheme.typography.fontBodyMdRegular, Text(
text = spaceRoom.info(), modifier = Modifier.weight(1f),
fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null }, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, text = info,
maxLines = 1, fontStyle = FontStyle.Italic.takeIf { spaceRoom.name == null },
overflow = TextOverflow.Ellipsis color = ElementTheme.colors.textSecondary,
) maxLines = 1,
if (spaceRoom.state == CurrentUserMembership.INVITED) { overflow = TextOverflow.Ellipsis
Spacer(modifier = Modifier.height(12.dp))
InviteButtonsRowMolecule(
onAcceptClick = {},
onDeclineClick = {},
) )
} }
if (bottomAction != null) {
Spacer(modifier = Modifier.height(12.dp))
bottomAction()
}
} }
} }
@ -166,7 +172,8 @@ private fun SpaceRoomItemScaffold(
onLongClick: () -> Unit, onLongClick: () -> Unit,
hideAvatars: Boolean, hideAvatars: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit trailingAction: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) { ) {
val clickModifier = Modifier val clickModifier = Modifier
.combinedClickable( .combinedClickable(
@ -194,6 +201,10 @@ private fun SpaceRoomItemScaffold(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
content = content, content = content,
) )
if (trailingAction != null) {
Spacer(modifier = Modifier.width(16.dp))
trailingAction()
}
} }
} }
@ -233,3 +244,32 @@ private fun SpaceRoom.visibilityIcon(): ImageVector? {
CompoundIcons.LockSolid() 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,
),
)
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fbdebc1c9361339dd0db1051e59162ed62fa2787c46457848da5ee6a30474588 oid sha256:cc1c98131fdad16e22c63feb72536fee912ae84d763c57ff76eaee6d61889b4b
size 106570 size 127697

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7bb7d1c08f5b2551117aa1aff8a3afbabd0f4100e0fae11cf415bd8e851c0307 oid sha256:4ed12d6dc439b7e7d86580ee24a5cd5c3b5e86b13c4f3cf3e64f677632df5872
size 103835 size 124816

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8eb29dd4b1667250d9bdf880bbf16fe343ac40704df793b1ba0725cbc036a56 oid sha256:2d5111d6ea6b79a4085b65c5d0390d8bbe8d200cc8e50bd9f3cd568ac9ab6179
size 46929 size 53740

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:48a538ac752a6f0a84d09bb6b81a7c7c8f47f01537d500de28040cb859db8498 oid sha256:377cf0bfc11eb067767b089cc5f35ff92307e3512eb30b6f9d51780acf590f9d
size 45652 size 52891

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0c95c84150a78322d6b101fe5ee4b9d0c8436ff9792ca87aa1d4394a0f86a345 oid sha256:75d18c001040426b961c5f7d0115a2dbad7e3cd1eb8a7af09357cdda1cc591a4
size 46364 size 52470

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5c88b9691b4dc811a5504dca5baa75f46dca6083b161ddf3c2e71050aa449d59 oid sha256:26e17ed257ded386bf0538c68f6848841f28d4ffd04a8d3f7d3b41feb203deb2
size 44862 size 51517

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6cb722a95b3ee29f751d053ea1cab634c3181b07678449600a2c23a97de0fcd1
size 16496

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:684f50c937b0ab7413cdff653a11ed11796583bf2182bfc1bbc1725f42d3a0ba
size 13073

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f6b5eaa4a84fc70cab57d37737b390583d3af9f6e829cecffbcf36351bd40e2
size 23562

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f271f630d93fdf49436509d08276b6a444db92c06fd06129af93cffbb4623d4
size 17931

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9fed82716640b4c96b6ef666d9f992e966c17ebddc275c5f562b412e2d549b0
size 15065

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79e7cecc4b03937d2a030721383664d54466e7ce25459c3628ace8b36b305214
size 35293

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7798eeef4a1de3312ec69b757963c6430d3952d45db6346961f79dae4e15913a
size 41389

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7798eeef4a1de3312ec69b757963c6430d3952d45db6346961f79dae4e15913a
size 41389

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f07736b16ece457794cdbd3859ede26ed0aa4b53acdc69d7f913df9a9b0fad1f
size 16002

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ad16996462532092686f1638f3ee1f40ede99b5ccb2bc53e57931f50920e62b5
size 12629

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:690df48431acad687f2a2e72aebba4fa63de433ed68b7b9e19818a78973cd211
size 22676

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34073398379389aa862c892e6eaa33574606071597a0095dfa5c84b99154b5df
size 17218

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:13d555688a963f8bf664c8bafc397a48c31575d4a2c2fa06a20b191e620d23ea
size 14587

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ac86fef06b52c5456c5eb828c1b60615792906f74d15ea35e3289d2cff47ee1
size 34183

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b431eae85dcb3e5efde883a47c78aab6c346429366357bb5f6e740342eeeb97
size 40072

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b431eae85dcb3e5efde883a47c78aab6c346429366357bb5f6e740342eeeb97
size 40072