feature (space) : start logic for joining space

This commit is contained in:
ganfra 2025-09-15 16:26:54 +02:00
parent 4869c0b5d7
commit e79281a78a
11 changed files with 167 additions and 81 deletions

View file

@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.parcelize.Parcelize
@Parcelize
@ -36,3 +37,11 @@ fun RoomInfo.toInviteData(): InviteData {
isDm = isDm,
)
}
fun SpaceRoom.toInviteData(): InviteData {
return InviteData(
roomId = roomId,
roomName = name ?: roomId.value,
isDm = false,
)
}

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.JoinedRoom
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
@ -41,18 +42,23 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ErrorKind
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipDetails
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@Inject
class JoinRoomPresenter(
@ -80,6 +86,8 @@ class JoinRoomPresenter(
): JoinRoomPresenter
}
private val spaceList = matrixClient.spaceService.spaceRoomList(roomId)
@Composable
override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
@ -87,6 +95,9 @@ class JoinRoomPresenter(
val roomInfo by remember {
matrixClient.getRoomInfoFlow(roomId)
}.collectAsState(initial = Optional.empty())
val spaceRoom by remember {
spaceList.currentSpaceFlow()
}.collectAsState()
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
@ -96,55 +107,41 @@ class JoinRoomPresenter(
val hideInviteAvatars by matrixClient.rememberHideInvitesAvatar()
val canReportRoom by produceState(false) { value = matrixClient.canReportRoom() }
val contentState by produceState<ContentState>(
initialValue = ContentState.Loading,
key1 = roomInfo,
key2 = retryCount,
key3 = isDismissingContent,
) {
var contentState by remember {
mutableStateOf<ContentState>(ContentState.Loading)
}
LaunchedEffect(roomInfo, retryCount, isDismissingContent, spaceRoom) {
when {
isDismissingContent -> value = ContentState.Dismissing
isDismissingContent -> contentState = ContentState.Dismissing
roomInfo.isPresent -> {
val notJoinedRoom = matrixClient.getRoomPreview(roomIdOrAlias, serverNames).getOrNull()
val (sender, reason) = when (roomInfo.get().currentUserMembership) {
CurrentUserMembership.BANNED -> {
// Workaround to get info about the sender for banned rooms
// TODO re-do this once we have a better API in the SDK
val membershipDetails = notJoinedRoom?.membershipDetails()?.getOrNull()
membershipDetails?.senderMember to membershipDetails?.currentUserMember?.membershipChangeReason
}
CurrentUserMembership.INVITED -> {
roomInfo.get().inviter to null
}
else -> null to null
}
val membershipDetails = notJoinedRoom?.membershipDetails()?.getOrNull()
val joinedMembersCountOverride = notJoinedRoom?.previewInfo?.numberOfJoinedMembers
value = roomInfo.get().toContentState(
membershipSender = sender,
contentState = roomInfo.get().toContentState(
joinedMembersCountOverride = joinedMembersCountOverride,
reason = reason,
membershipDetails = membershipDetails,
childrenCount = spaceRoom.getOrNull()?.childrenCount,
)
}
spaceRoom.isPresent -> {
val spaceRoom = spaceRoom.get()
// Only use this state when space is not locally known
contentState = if (spaceRoom.state != null) {
ContentState.Loading
} else {
spaceRoom.toContentState()
}
}
roomDescription.isPresent -> {
value = roomDescription.get().toContentState()
contentState = roomDescription.get().toContentState()
}
else -> {
value = ContentState.Loading
contentState = ContentState.Loading
val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames)
value = result.fold(
contentState = result.fold(
onSuccess = { preview ->
val membershipInfo = when (preview.previewInfo.membership) {
CurrentUserMembership.INVITED,
CurrentUserMembership.BANNED,
CurrentUserMembership.KNOCKED -> {
preview.membershipDetails().getOrNull()
}
else -> null
}
preview.previewInfo.toContentState(
senderMember = membershipInfo?.senderMember,
reason = membershipInfo?.currentUserMember?.membershipChangeReason,
)
val membershipDetails = preview.membershipDetails().getOrNull()
preview.previewInfo.toContentState(membershipDetails)
},
onFailure = { throwable ->
if (throwable is ClientException.MatrixApi && (throwable.kind == ErrorKind.NotFound || throwable.kind == ErrorKind.Forbidden)) {
@ -223,6 +220,15 @@ class JoinRoomPresenter(
}
}
private suspend fun getRoomPreviewIfKnown(membership: CurrentUserMembership?): NotJoinedRoom? {
return when (membership) {
CurrentUserMembership.INVITED,
CurrentUserMembership.KNOCKED,
CurrentUserMembership.BANNED -> matrixClient.getRoomPreview(roomIdOrAlias, serverNames).getOrNull()
else -> null
}
}
private fun CoroutineScope.knockRoom(knockAction: MutableState<AsyncAction<Unit>>, message: String) = launch {
knockAction.runUpdatingState {
knockRoom(roomIdOrAlias, message, serverNames)
@ -252,7 +258,7 @@ class JoinRoomPresenter(
}
}
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
private fun RoomPreviewInfo.toContentState(membershipDetails: RoomMembershipDetails?): ContentState {
return ContentState.Loaded(
roomId = roomId,
name = name,
@ -262,17 +268,37 @@ private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: St
isDm = false,
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (membership) {
CurrentUserMembership.INVITED -> {
JoinAuthorisationStatus.IsInvited(
inviteData = toInviteData(),
inviteSender = senderMember?.toInviteSender()
)
}
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(senderMember?.toInviteSender(), reason)
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus()
}
joinAuthorisationStatus = computeJoinAuthorisationStatus(
membership,
membershipDetails,
joinRule,
{ toInviteData() }
),
joinRule = joinRule,
childrenCount = null,
heroes = persistentListOf(),
)
}
private fun SpaceRoom.toContentState(): ContentState {
return ContentState.Loaded(
roomId = roomId,
name = name,
topic = topic,
alias = canonicalAlias,
numberOfMembers = numJoinedMembers.toLong(),
isDm = false,
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = computeJoinAuthorisationStatus(
membership = state,
membershipDetails = null,
joinRule = joinRule,
inviteData = { toInviteData() }
),
childrenCount = childrenCount,
joinRule = joinRule,
heroes = heroes.toPersistentList(),
)
}
@ -291,15 +317,25 @@ internal fun RoomDescription.toContentState(): ContentState {
RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock
RoomDescription.JoinRule.PUBLIC -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
}
},
childrenCount = null,
joinRule = when (joinRule) {
RoomDescription.JoinRule.KNOCK -> JoinRule.Knock
RoomDescription.JoinRule.PUBLIC -> JoinRule.Public
RoomDescription.JoinRule.RESTRICTED -> JoinRule.Restricted(persistentListOf())
RoomDescription.JoinRule.KNOCK_RESTRICTED -> JoinRule.KnockRestricted(persistentListOf())
RoomDescription.JoinRule.INVITE -> JoinRule.Invite
RoomDescription.JoinRule.UNKNOWN -> null
},
heroes = persistentListOf()
)
}
@VisibleForTesting
internal fun RoomInfo.toContentState(
membershipSender: RoomMember?,
joinedMembersCountOverride: Long?,
reason: String?,
membershipDetails: RoomMembershipDetails?,
childrenCount: Int?,
): ContentState {
return ContentState.Loaded(
roomId = id,
@ -310,21 +346,40 @@ internal fun RoomInfo.toContentState(
isDm = isDm,
roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (currentUserMembership) {
CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteData = toInviteData(),
inviteSender = membershipSender?.toInviteSender(),
)
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(
banSender = membershipSender?.toInviteSender(),
reason = reason,
)
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus()
}
joinAuthorisationStatus = computeJoinAuthorisationStatus(
membership = currentUserMembership,
membershipDetails = membershipDetails,
joinRule = joinRule,
inviteData = { toInviteData() }
),
joinRule = joinRule,
childrenCount = childrenCount,
heroes = heroes
)
}
private fun computeJoinAuthorisationStatus(
membership: CurrentUserMembership?,
membershipDetails: RoomMembershipDetails?,
joinRule: JoinRule?,
inviteData: () -> InviteData,
): JoinAuthorisationStatus {
return when (membership) {
CurrentUserMembership.INVITED -> {
JoinAuthorisationStatus.IsInvited(
inviteData = inviteData(),
inviteSender = membershipDetails?.senderMember?.toInviteSender()
)
}
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(
membershipDetails?.senderMember?.toInviteSender(),
membershipDetails?.membershipChangeReason
)
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus()
}
}
private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
return when (this) {
JoinRule.Knock,

View file

@ -18,7 +18,10 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.ImmutableList
internal const val MAX_KNOCK_MESSAGE_LENGTH = 500
@ -41,9 +44,6 @@ data class JoinRoomState(
val joinAuthorisationStatus = when (contentState) {
is ContentState.Loaded -> {
when {
contentState.roomType == RoomType.Space -> {
JoinAuthorisationStatus.IsSpace(applicationName)
}
isJoinActionUnauthorized -> {
JoinAuthorisationStatus.Unauthorized
}
@ -81,8 +81,12 @@ sealed interface ContentState {
val roomType: RoomType,
val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus,
val joinRule: JoinRule?,
val childrenCount: Int?,
val heroes: ImmutableList<MatrixUser>,
) : ContentState {
val showMemberCount = numberOfMembers != null
val isSpace = roomType is RoomType.Space
fun avatarData(size: AvatarSize): AvatarData {
return AvatarData(

View file

@ -22,7 +22,10 @@ import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.InviteSender
import kotlinx.collections.immutable.toPersistentList
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState>
@ -78,6 +81,7 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
alias = null,
topic = "This is the topic of a space",
roomType = RoomType.Space,
childrenCount = 42,
)
),
aJoinRoomState(
@ -160,6 +164,9 @@ fun aLoadedContentState(
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown,
childrenCount: Int? = null,
joinRule : JoinRule? = null,
heroes: List<MatrixUser> = emptyList()
) = ContentState.Loaded(
roomId = roomId,
name = name,
@ -169,7 +176,10 @@ fun aLoadedContentState(
isDm = isDm,
roomType = roomType,
roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus
joinAuthorisationStatus = joinAuthorisationStatus,
childrenCount = childrenCount,
joinRule = joinRule,
heroes = heroes.toPersistentList()
)
fun aJoinRoomState(

View file

@ -964,7 +964,7 @@ class JoinRoomPresenterTest {
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsSpace("AppName"))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.UnsupportedSpace("AppName"))
}
}
}

View file

@ -29,6 +29,8 @@ import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
@Inject
class SpacePresenter(
@ -64,7 +66,7 @@ class SpacePresenter(
}
}.collectAsState()
val currentSpace by remember { spaceRoomList.currentSpaceFlow() }.collectAsState(null)
val currentSpace by remember { spaceRoomList.currentSpaceFlow() }.collectAsState(Optional.empty())
fun handleEvents(event: SpaceEvents) {
when (event) {
@ -72,7 +74,7 @@ class SpacePresenter(
}
}
return SpaceState(
currentSpace = currentSpace,
currentSpace = currentSpace.getOrNull(),
children = children.toPersistentList(),
seenSpaceInvites = seenSpaceInvites,
hideInvitesAvatar = hideInvitesAvatar,

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.api.spaces
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.Optional
interface SpaceRoomList {
sealed interface PaginationStatus {
@ -16,7 +17,7 @@ interface SpaceRoomList {
data class Idle(val hasMoreToLoad: Boolean) : PaginationStatus
}
fun currentSpaceFlow(): Flow<SpaceRoom?>
fun currentSpaceFlow(): StateFlow<Optional<SpaceRoom>>
val spaceRoomsFlow: Flow<List<SpaceRoom>>
val paginationStatusFlow: StateFlow<PaginationStatus>

View file

@ -16,10 +16,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState
import java.util.Optional
import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList
class RustSpaceRoomList(
@ -31,7 +33,7 @@ class RustSpaceRoomList(
) : SpaceRoomList {
private val inner = CompletableDeferred<InnerSpaceRoomList>()
override fun currentSpaceFlow(): Flow<SpaceRoom?> {
override fun currentSpaceFlow(): StateFlow<Optional<SpaceRoom>> {
return spaceRoomCache.getSpaceRoomFlow(roomId)
}

View file

@ -12,7 +12,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import java.util.Optional
/**
* An in memory cache of space rooms.
@ -20,8 +22,8 @@ import kotlinx.coroutines.flow.update
*/
class SpaceRoomCache {
private val inMemoryCache = MutableStateFlow<Map<RoomId, SpaceRoom?>>(emptyMap())
fun getSpaceRoomFlow(roomId: RoomId): Flow<SpaceRoom?> {
return inMemoryCache.mapState { it[roomId] }
fun getSpaceRoomFlow(roomId: RoomId): StateFlow<Optional<SpaceRoom>> {
return inMemoryCache.mapState { Optional.ofNullable(it[roomId]) }
}
fun update(spaceRooms: List<SpaceRoom>) {

View file

@ -21,7 +21,7 @@ class SpaceRoomCacheTest {
fun `getSpaceRoomFlow emits items`() = runTest {
val sut = SpaceRoomCache()
sut.getSpaceRoomFlow(A_ROOM_ID).test {
assertThat(awaitItem()).isNull()
assertThat(awaitItem().isEmpty).isTrue()
val room = aSpaceRoom(
roomId = A_ROOM_ID,
roomType = RoomType.Room,
@ -34,7 +34,7 @@ class SpaceRoomCacheTest {
roomType = RoomType.Space,
)
sut.update(listOf(space))
assertThat(awaitItem()).isEqualTo(space)
assertThat(awaitItem().get()).isEqualTo(space)
val spaceOther = aSpaceRoom(
roomId = A_ROOM_ID_2,
roomType = RoomType.Space,

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Optional
class FakeSpaceRoomList(
initialSpaceFlowValue: SpaceRoom? = null,
@ -22,11 +23,11 @@ class FakeSpaceRoomList(
initialSpaceRoomList: SpaceRoomList.PaginationStatus = SpaceRoomList.PaginationStatus.Loading,
private val paginateResult: () -> Result<Unit> = { lambdaError() },
) : SpaceRoomList {
private val _currentSpaceFlow: MutableStateFlow<SpaceRoom?> = MutableStateFlow(initialSpaceFlowValue)
override fun currentSpaceFlow(): Flow<SpaceRoom?> = _currentSpaceFlow.asStateFlow()
private val _currentSpaceFlow: MutableStateFlow<Optional<SpaceRoom>> = MutableStateFlow(Optional.ofNullable(initialSpaceFlowValue))
override fun currentSpaceFlow() = _currentSpaceFlow.asStateFlow()
fun emitCurrentSpace(value: SpaceRoom?) {
_currentSpaceFlow.value = value
_currentSpaceFlow.value = Optional.ofNullable(value)
}
private val _spaceRoomsFlow: MutableStateFlow<List<SpaceRoom>> = MutableStateFlow(initialSpaceRoomsValue)