Add RoomMembershipDetails to get the room member info for the current user and the sender of its m.room.member state event in the room.

This commit is contained in:
Jorge Martín 2025-02-10 16:20:34 +01:00 committed by Jorge Martin Espinosa
parent 311fbb84f7
commit 02addf54dd
11 changed files with 101 additions and 17 deletions

View file

@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.exception.ErrorKind
import io.element.android.libraries.matrix.api.getRoomInfoFlow import io.element.android.libraries.matrix.api.getRoomInfoFlow
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.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomType 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.isDm
import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.join.JoinRoom
@ -97,7 +98,20 @@ class JoinRoomPresenter @AssistedInject constructor(
when { when {
isDismissingContent -> value = ContentState.Dismissing isDismissingContent -> value = ContentState.Dismissing
roomInfo.isPresent -> { roomInfo.isPresent -> {
value = roomInfo.get().toContentState() 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 preview = matrixClient.getRoomPreview(roomIdOrAlias, serverNames)
val membershipDetalis = preview.getOrNull()?.membershipDetails()?.getOrNull()
membershipDetalis?.senderMember to membershipDetalis?.currentUserMember?.membershipChangeReason
}
CurrentUserMembership.INVITED -> {
roomInfo.get().inviter to null
}
else -> null to null
}
value = roomInfo.get().toContentState(sender, reason)
} }
roomDescription.isPresent -> { roomDescription.isPresent -> {
value = roomDescription.get().toContentState() value = roomDescription.get().toContentState()
@ -106,10 +120,19 @@ class JoinRoomPresenter @AssistedInject constructor(
value = ContentState.Loading value = ContentState.Loading
val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames) val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames)
value = result.fold( value = result.fold(
onSuccess = { previewInfo ->
previewInfo.toContentState()
onSuccess = { preview -> onSuccess = { preview ->
preview.info.toContentState() val membershipInfo = when (preview.info.membership) {
CurrentUserMembership.INVITED,
CurrentUserMembership.BANNED,
CurrentUserMembership.KNOCKED -> {
preview.membershipDetails().getOrNull()
}
else -> null
}
preview.info.toContentState(
senderMember = membershipInfo?.senderMember,
reason = membershipInfo?.currentUserMember?.membershipChangeReason,
)
}, },
onFailure = { throwable -> onFailure = { throwable ->
if (throwable is ClientException.MatrixApi && (throwable.kind == ErrorKind.NotFound || throwable.kind == ErrorKind.Forbidden)) { if (throwable is ClientException.MatrixApi && (throwable.kind == ErrorKind.NotFound || throwable.kind == ErrorKind.Forbidden)) {
@ -213,7 +236,7 @@ class JoinRoomPresenter @AssistedInject constructor(
} }
} }
private fun RoomPreviewInfo.toContentState(): ContentState { private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {
return ContentState.Loaded( return ContentState.Loaded(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -224,8 +247,8 @@ private fun RoomPreviewInfo.toContentState(): ContentState {
roomType = roomType, roomType = roomType,
roomAvatarUrl = avatarUrl, roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (membership) { joinAuthorisationStatus = when (membership) {
CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(null) CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(senderMember?.toInviteSender())
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(null) CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(senderMember?.toInviteSender(), reason)
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus() else -> joinRule.toJoinAuthorisationStatus()
} }
@ -252,7 +275,7 @@ internal fun RoomDescription.toContentState(): ContentState {
} }
@VisibleForTesting @VisibleForTesting
internal fun MatrixRoomInfo.toContentState(): ContentState { internal fun MatrixRoomInfo.toContentState(membershipSender: RoomMember?, reason: String?): ContentState {
return ContentState.Loaded( return ContentState.Loaded(
roomId = id, roomId = id,
name = name, name = name,
@ -264,10 +287,11 @@ internal fun MatrixRoomInfo.toContentState(): ContentState {
roomAvatarUrl = avatarUrl, roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (currentUserMembership) { joinAuthorisationStatus = when (currentUserMembership) {
CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited( CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteSender = inviter?.toInviteSender() inviteSender = membershipSender?.toInviteSender()
) )
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned( CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(
banSender = inviter?.toInviteSender() banSender = membershipSender?.toInviteSender(),
reason = reason,
) )
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus() else -> joinRule.toJoinAuthorisationStatus()

View file

@ -93,7 +93,7 @@ sealed interface JoinAuthorisationStatus {
data object None : JoinAuthorisationStatus data object None : JoinAuthorisationStatus
data class IsSpace(val applicationName: String) : JoinAuthorisationStatus data class IsSpace(val applicationName: String) : JoinAuthorisationStatus
data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
data class IsBanned(val banSender: InviteSender?) : JoinAuthorisationStatus data class IsBanned(val banSender: InviteSender?, val reason: String?) : JoinAuthorisationStatus
data object IsKnocked : JoinAuthorisationStatus data object IsKnocked : JoinAuthorisationStatus
data object CanKnock : JoinAuthorisationStatus data object CanKnock : JoinAuthorisationStatus
data object CanJoin : JoinAuthorisationStatus data object CanJoin : JoinAuthorisationStatus

View file

@ -115,12 +115,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
contentState = aLoadedContentState( contentState = aLoadedContentState(
name = "A banned room", name = "A banned room",
joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned( joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(
InviteSender( banSender = InviteSender(
userId = UserId("@alice:domain"), userId = UserId("@alice:domain"),
displayName = "Alice", displayName = "Alice",
avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender), avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender),
membershipChangeReason = "spamming" membershipChangeReason = "spamming"
) ),
reason = "spamming",
), ),
) )
), ),

View file

@ -287,8 +287,8 @@ private fun JoinBannedFooter(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
val banReason = status.banSender?.membershipChangeReason?.let { val banReason = status.reason?.let {
stringResource(R.string.screen_join_room_ban_reason, it) stringResource(R.string.screen_join_room_ban_reason, it.removeSuffix("."))
} }
val title = if (status.banSender != null) { val title = if (status.banSender != null) {
stringResource(R.string.screen_join_room_ban_by_message, status.banSender.displayName) stringResource(R.string.screen_join_room_ban_by_message, status.banSender.displayName)

View file

@ -28,15 +28,19 @@ 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.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ErrorKind 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.CurrentUserMembership
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.RoomType
import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.test.AN_EXCEPTION 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
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SERVER_LIST import io.element.android.libraries.matrix.test.A_SERVER_LIST
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_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.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomPreview
import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo import io.element.android.libraries.matrix.test.room.aRoomPreviewInfo
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
@ -47,6 +51,7 @@ import io.element.android.tests.testutils.lambda.assert
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.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -253,10 +258,10 @@ class JoinRoomPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest { fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest {
val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public) val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public)
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
getRoomPreviewResult = { _, _ -> getRoomPreviewResult = { _, _ ->
Result.success( Result.success(
@ -266,6 +271,14 @@ class JoinRoomPresenterTest {
joinRule = JoinRule.Public, joinRule = JoinRule.Public,
currentUserMembership = CurrentUserMembership.BANNED, currentUserMembership = CurrentUserMembership.BANNED,
), ),
roomMembershipDetails = {
Result.success(
RoomMembershipDetails(
currentUserMember = aRoomMember(userId = A_USER_ID, displayName = "Alice"),
senderMember = aRoomMember(userId = A_USER_ID_2, displayName = "Bob"),
)
)
}
) )
) )
} }
@ -278,7 +291,13 @@ class JoinRoomPresenterTest {
matrixClient = matrixClient matrixClient = matrixClient
) )
presenter.test { presenter.test {
// Skip initial state
skipItems(1) skipItems(1)
// Advance until the room info is loaded and the presenter recomposes. The room preview info still needs to be loaded async.
skipItems(1)
// Now we should have the room info
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isInstanceOf(JoinAuthorisationStatus.IsBanned::class.java) assertThat(state.joinAuthorisationStatus).isInstanceOf(JoinAuthorisationStatus.IsBanned::class.java)
} }

View file

@ -213,7 +213,7 @@ class JoinRoomViewTest {
val eventsRecorder = EventsRecorder<JoinRoomEvents>() val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView( rule.setJoinRoomView(
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null)), contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)),
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )

View file

@ -0,0 +1,20 @@
/*
* 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.api.room
/**
* Room membership details for the current user and the sender of the membership event.
*
* It also includes the reason the current user's membership changed, if any.
*/
data class RoomMembershipDetails(
val currentUserMember: RoomMember,
val senderMember: RoomMember?,
) {
val membershipChangeReason: String? = currentUserMember.membershipChangeReason
}

View file

@ -22,4 +22,9 @@ interface RoomPreview : AutoCloseable {
* Forget the room if we had access to it, and it was left or banned. * Forget the room if we had access to it, and it was left or banned.
*/ */
suspend fun forget(): Result<Unit> suspend fun forget(): Result<Unit>
/**
* Get the membership details of the user in the room, as well as from the user who sent the `m.room.member` event.
*/
suspend fun membershipDetails(): Result<RoomMembershipDetails?>
} }

View file

@ -40,6 +40,14 @@ class RustRoomPreview(
inner.forget() inner.forget()
} }
override suspend fun membershipDetails(): Result<RoomMembershipDetails?> = runCatching {
val details = inner.ownMembershipDetails() ?: return@runCatching null
RoomMembershipDetails(
currentUserMember = RoomMemberMapper.map(details.ownRoomMember),
senderMember = details.senderRoomMember?.let { RoomMemberMapper.map(it) },
)
}
override fun close() { override fun close() {
inner.destroy() inner.destroy()
} }

View file

@ -22,6 +22,7 @@ class FakeRoomPreview(
override val info: RoomPreviewInfo = aRoomPreviewInfo(), override val info: RoomPreviewInfo = aRoomPreviewInfo(),
private val declineInviteResult: () -> Result<Unit> = { lambdaError() }, private val declineInviteResult: () -> Result<Unit> = { lambdaError() },
private val forgetRoomResult: () -> Result<Unit> = { lambdaError() }, private val forgetRoomResult: () -> Result<Unit> = { lambdaError() },
private val roomMembershipDetails: () -> Result<RoomMembershipDetails?> = { lambdaError() },
) : RoomPreview { ) : RoomPreview {
override suspend fun leave(): Result<Unit> = simulateLongTask { override suspend fun leave(): Result<Unit> = simulateLongTask {
declineInviteResult() declineInviteResult()
@ -31,5 +32,9 @@ class FakeRoomPreview(
forgetRoomResult() forgetRoomResult()
} }
override suspend fun membershipDetails(): Result<RoomMembershipDetails?> = simulateLongTask {
roomMembershipDetails()
}
override fun close() = Unit override fun close() = Unit
} }

View file

@ -27,11 +27,13 @@ fun aRoomPreview(
info: RoomPreviewInfo = aRoomPreviewInfo(), info: RoomPreviewInfo = aRoomPreviewInfo(),
declineInviteResult: () -> Result<Unit> = { lambdaError() }, declineInviteResult: () -> Result<Unit> = { lambdaError() },
forgetRoomResult: () -> Result<Unit> = { lambdaError() }, forgetRoomResult: () -> Result<Unit> = { lambdaError() },
roomMembershipDetails: () -> Result<RoomMembershipDetails?> = { lambdaError() },
) = FakeRoomPreview( ) = FakeRoomPreview(
sessionId = sessionId, sessionId = sessionId,
info = info, info = info,
declineInviteResult = declineInviteResult, declineInviteResult = declineInviteResult,
forgetRoomResult = forgetRoomResult, forgetRoomResult = forgetRoomResult,
roomMembershipDetails = roomMembershipDetails,
) )
fun aRoomPreviewInfo( fun aRoomPreviewInfo(