feature (space) : handle accept decline invite

This commit is contained in:
ganfra 2025-09-30 15:59:29 +02:00
parent de4e3d8735
commit dbffad29d0
9 changed files with 135 additions and 30 deletions

View file

@ -13,4 +13,6 @@ sealed interface SpaceEvents {
data object LoadMore : SpaceEvents data object LoadMore : SpaceEvents
data class Join(val spaceRoom: SpaceRoom) : SpaceEvents data class Join(val spaceRoom: SpaceRoom) : SpaceEvents
data object ClearFailures : 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.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
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,6 +37,7 @@ class SpaceNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory, presenterFactory: SpacePresenter.Factory,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
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>)
@ -79,6 +81,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

@ -9,7 +9,6 @@ package io.element.android.features.space.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -20,6 +19,9 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom 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.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -47,6 +49,7 @@ import kotlin.jvm.optionals.getOrNull
private val client: MatrixClient, private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore, private val seenInvitesStore: SeenInvitesStore,
private val joinRoom: JoinRoom, private val joinRoom: JoinRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
) : Presenter<SpaceState> { ) : Presenter<SpaceState> {
@AssistedFactory fun interface Factory { @AssistedFactory fun interface Factory {
@ -77,26 +80,39 @@ import kotlin.jvm.optionals.getOrNull
}.collectAsState() }.collectAsState()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val joinActions = remember { mutableStateOf(emptyMap<RoomId, AsyncAction<Unit>>()) } val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap<RoomId, AsyncAction<Unit>>()) }
LaunchedEffect(children) { LaunchedEffect(children) {
val joinedChildren = children.filter { it.state == CurrentUserMembership.JOINED }.map { it.roomId }.toSet() // Remove joined children from the join actions
joinActions.value.let { currentlyJoining -> val joinedChildren = children
joinActions.value = currentlyJoining - joinedChildren .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 -> localCoroutineScope.paginate() SpaceEvents.LoadMore -> localCoroutineScope.paginate()
is SpaceEvents.Join -> { is SpaceEvents.Join -> {
sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions) sessionCoroutineScope.joinRoom(event.spaceRoom, joinActions, setJoinActions)
} }
SpaceEvents.ClearFailures -> { SpaceEvents.ClearFailures -> {
val failedActions = joinActions.value val failedActions = joinActions
.filterValues { it is AsyncAction.Failure } .filterValues { it is AsyncAction.Failure }
.mapValues { AsyncAction.Uninitialized } .mapValues { AsyncAction.Uninitialized }
joinActions.value = joinActions.value + failedActions 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)
)
} }
} }
} }
@ -106,21 +122,24 @@ import kotlin.jvm.optionals.getOrNull
seenSpaceInvites = seenSpaceInvites, seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
joinActions = joinActions.value.toPersistentMap(), joinActions = joinActions.toPersistentMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun CoroutineScope.joinRoom( private fun CoroutineScope.joinRoom(
spaceRoom: SpaceRoom, joiningRooms: MutableState<Map<RoomId, AsyncAction<Unit>>> spaceRoom: SpaceRoom,
joinActions: Map<RoomId, AsyncAction<Unit>>,
setJoinActions: (Map<RoomId, AsyncAction<Unit>>) -> Unit
) = launch { ) = launch {
joiningRooms.value = joiningRooms.value + mapOf(spaceRoom.roomId to AsyncAction.Loading) setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Loading))
joinRoom.invoke( joinRoom.invoke(
roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(), roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(),
serverNames = spaceRoom.via, serverNames = spaceRoom.via,
trigger = JoinedRoom.Trigger.SpaceHierarchy, trigger = JoinedRoom.Trigger.SpaceHierarchy,
).onFailure { ).onFailure {
joiningRooms.value = joiningRooms.value + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)) setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it)))
} }
} }

View file

@ -7,6 +7,7 @@
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.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
@ -21,6 +22,7 @@ data class SpaceState(
val hideInvitesAvatar: Boolean, val hideInvitesAvatar: Boolean,
val hasMoreToLoad: Boolean, val hasMoreToLoad: Boolean,
val joinActions: ImmutableMap<RoomId, AsyncAction<Unit>>, 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 fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading

View file

@ -8,6 +8,8 @@
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.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
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
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
@ -54,6 +56,7 @@ fun aSpaceState(
joiningRooms: Set<RoomId> = emptySet(), joiningRooms: Set<RoomId> = emptySet(),
hideInvitesAvatar: Boolean = false, hideInvitesAvatar: Boolean = false,
hasMoreToLoad: Boolean = false, hasMoreToLoad: Boolean = false,
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
) = SpaceState( ) = SpaceState(
currentSpace = parentSpace, currentSpace = parentSpace,
children = children.toImmutableList(), children = children.toImmutableList(),
@ -61,9 +64,20 @@ fun aSpaceState(
hideInvitesAvatar = hideInvitesAvatar, hideInvitesAvatar = hideInvitesAvatar,
hasMoreToLoad = hasMoreToLoad, hasMoreToLoad = hasMoreToLoad,
joinActions = joiningRooms.associateWith { AsyncAction.Uninitialized }.toImmutableMap(), joinActions = joiningRooms.associateWith { AsyncAction.Uninitialized }.toImmutableMap(),
acceptDeclineInviteState = acceptDeclineInviteState,
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,
)
private fun aListOfSpaceRooms(): List<SpaceRoom> { private fun aListOfSpaceRooms(): List<SpaceRoom> {
return listOf( return listOf(
aSpaceRoom( aSpaceRoom(

View file

@ -34,6 +34,7 @@ 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.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost 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.async.rememberAsyncIndicatorState
@ -71,6 +72,7 @@ fun SpaceView(
onShareSpace: () -> Unit, onShareSpace: () -> Unit,
onLeaveSpaceClick: () -> Unit, onLeaveSpaceClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
@ -94,6 +96,7 @@ fun SpaceView(
hasAnyFailure = state.hasAnyFailure, hasAnyFailure = state.hasAnyFailure,
eventSink = state.eventSink eventSink = state.eventSink
) )
acceptDeclineInviteView()
} }
}, },
) )
@ -157,7 +160,15 @@ private fun SpaceViewContent(
}, },
trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) {
state.eventSink(SpaceEvents.Join(spaceRoom)) state.eventSink(SpaceEvents.Join(spaceRoom))
} },
bottomAction = spaceRoom.inviteButtons(
onAcceptClick = {
state.eventSink(SpaceEvents.AcceptInvite(spaceRoom))
},
onDeclineClick = {
state.eventSink(SpaceEvents.DeclineInvite(spaceRoom))
}
)
) )
} }
} }
@ -311,6 +322,23 @@ private fun SpaceRoom.trailingAction(
} }
} }
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(
@ -321,6 +349,7 @@ internal fun SpaceViewPreview(
onRoomClick = {}, onRoomClick = {},
onShareSpace = {}, onShareSpace = {},
onLeaveSpaceClick = {}, onLeaveSpaceClick = {},
acceptDeclineInviteView = {},
onBackClick = {}, onBackClick = {},
) )
} }

View file

@ -11,8 +11,10 @@ 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.AcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.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.room.join.JoinRoom 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
@ -163,12 +165,14 @@ class SpacePresenterTest {
joinRoom: JoinRoom = FakeJoinRoom( joinRoom: JoinRoom = FakeJoinRoom(
lambda = { _, _, _ -> Result.success(Unit) }, lambda = { _, _, _ -> Result.success(Unit) },
), ),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
): SpacePresenter { ): SpacePresenter {
return SpacePresenter( return SpacePresenter(
inputs = inputs, inputs = inputs,
client = client, client = client,
seenInvitesStore = seenInvitesStore, seenInvitesStore = seenInvitesStore,
joinRoom = joinRoom, joinRoom = joinRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
) )
} }

View file

@ -63,6 +63,7 @@ fun SpaceRoomItemView(
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingAction: @Composable (() -> Unit)? = null, trailingAction: @Composable (() -> Unit)? = null,
bottomAction: @Composable (() -> Unit)? = null,
) { ) {
SpaceRoomItemScaffold( SpaceRoomItemScaffold(
modifier = modifier, modifier = modifier,
@ -84,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()
}
} }
} }
@ -253,6 +254,11 @@ internal fun SpaceRoomItemViewPreview(@PreviewParameter(SpaceRoomProvider::class
hideAvatars = false, hideAvatars = false,
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
bottomAction = if (spaceRoom.state == CurrentUserMembership.INVITED) {
{ InviteButtonsRowMolecule({}, {}) }
} else {
null
}
) )
} }

View file

@ -18,9 +18,24 @@ class SpaceRoomProvider : PreviewParameterProvider<SpaceRoom> {
override val values: Sequence<SpaceRoom> = sequenceOf( override val values: Sequence<SpaceRoom> = sequenceOf(
aSpaceRoom( aSpaceRoom(
roomType = RoomType.Room, roomType = RoomType.Room,
name = "Room name", name = "Room name with topic",
topic = "Room topic that is quite long and might be truncated" topic = "Room topic that is quite long and might be truncated"
), ),
aSpaceRoom(
roomType = RoomType.Room,
name = "Room name no topic",
),
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( aSpaceRoom(
numJoinedMembers = 5, numJoinedMembers = 5,
childrenCount = 10, childrenCount = 10,