diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index 1553543525..33a2d4db5b 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -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.room.CurrentUserMembership 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.isDm import io.element.android.libraries.matrix.api.room.join.JoinRoom @@ -97,7 +98,20 @@ class JoinRoomPresenter @AssistedInject constructor( when { isDismissingContent -> value = ContentState.Dismissing 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 -> { value = roomDescription.get().toContentState() @@ -106,10 +120,19 @@ class JoinRoomPresenter @AssistedInject constructor( value = ContentState.Loading val result = matrixClient.getRoomPreview(roomIdOrAlias, serverNames) value = result.fold( - onSuccess = { previewInfo -> - previewInfo.toContentState() 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 -> 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( roomId = roomId, name = name, @@ -224,8 +247,8 @@ private fun RoomPreviewInfo.toContentState(): ContentState { roomType = roomType, roomAvatarUrl = avatarUrl, joinAuthorisationStatus = when (membership) { - CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(null) - CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(null) + CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(senderMember?.toInviteSender()) + CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(senderMember?.toInviteSender(), reason) CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked else -> joinRule.toJoinAuthorisationStatus() } @@ -252,7 +275,7 @@ internal fun RoomDescription.toContentState(): ContentState { } @VisibleForTesting -internal fun MatrixRoomInfo.toContentState(): ContentState { +internal fun MatrixRoomInfo.toContentState(membershipSender: RoomMember?, reason: String?): ContentState { return ContentState.Loaded( roomId = id, name = name, @@ -264,10 +287,11 @@ internal fun MatrixRoomInfo.toContentState(): ContentState { roomAvatarUrl = avatarUrl, joinAuthorisationStatus = when (currentUserMembership) { CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited( - inviteSender = inviter?.toInviteSender() + inviteSender = membershipSender?.toInviteSender() ) CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned( - banSender = inviter?.toInviteSender() + banSender = membershipSender?.toInviteSender(), + reason = reason, ) CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked else -> joinRule.toJoinAuthorisationStatus() diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt index 27ea7ad3f0..7162aea414 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -93,7 +93,7 @@ sealed interface JoinAuthorisationStatus { data object None : JoinAuthorisationStatus data class IsSpace(val applicationName: String) : 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 CanKnock : JoinAuthorisationStatus data object CanJoin : JoinAuthorisationStatus diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt index 30fcacb7c2..3ebf021cc1 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt @@ -115,12 +115,13 @@ open class JoinRoomStateProvider : PreviewParameterProvider { contentState = aLoadedContentState( name = "A banned room", joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned( - InviteSender( + banSender = InviteSender( userId = UserId("@alice:domain"), displayName = "Alice", avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender), membershipChangeReason = "spamming" - ) + ), + reason = "spamming", ), ) ), diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 753539d947..2deff455d9 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -287,8 +287,8 @@ private fun JoinBannedFooter( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - val banReason = status.banSender?.membershipChangeReason?.let { - stringResource(R.string.screen_join_room_ban_reason, it) + val banReason = status.reason?.let { + stringResource(R.string.screen_join_room_ban_reason, it.removeSuffix(".")) } val title = if (status.banSender != null) { stringResource(R.string.screen_join_room_ban_by_message, status.banSender.displayName) diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index 0d8c0a34ff..5febed1b70 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -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.ErrorKind 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.join.JoinRule 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_NAME 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.core.aBuildMeta 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.aRoomSummary 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.value import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -253,10 +258,10 @@ class JoinRoomPresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - when room is banned, then join authorization is equal to IsBanned`() = runTest { val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.BANNED, joinRule = JoinRule.Public) - val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient( getRoomPreviewResult = { _, _ -> Result.success( @@ -266,6 +271,14 @@ class JoinRoomPresenterTest { joinRule = JoinRule.Public, 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 ) presenter.test { + // Skip initial state 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 -> assertThat(state.joinAuthorisationStatus).isInstanceOf(JoinAuthorisationStatus.IsBanned::class.java) } diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt index 1c8909a271..b654bc7c4b 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt @@ -213,7 +213,7 @@ class JoinRoomViewTest { val eventsRecorder = EventsRecorder() rule.setJoinRoomView( aJoinRoomState( - contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null)), + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null, null)), eventSink = eventsRecorder, ), ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt new file mode 100644 index 0000000000..20d7b0e2ad --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMembershipDetails.kt @@ -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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt index 7da7d9e5dd..6dd8ac0992 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt @@ -22,4 +22,9 @@ interface RoomPreview : AutoCloseable { * Forget the room if we had access to it, and it was left or banned. */ suspend fun forget(): Result + + /** + * 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 } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt index 5706fa2c3a..0739a2cd0f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt @@ -40,6 +40,14 @@ class RustRoomPreview( inner.forget() } + override suspend fun membershipDetails(): Result = runCatching { + val details = inner.ownMembershipDetails() ?: return@runCatching null + RoomMembershipDetails( + currentUserMember = RoomMemberMapper.map(details.ownRoomMember), + senderMember = details.senderRoomMember?.let { RoomMemberMapper.map(it) }, + ) + } + override fun close() { inner.destroy() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt index 5dcac7f188..80908c5228 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt @@ -22,6 +22,7 @@ class FakeRoomPreview( override val info: RoomPreviewInfo = aRoomPreviewInfo(), private val declineInviteResult: () -> Result = { lambdaError() }, private val forgetRoomResult: () -> Result = { lambdaError() }, + private val roomMembershipDetails: () -> Result = { lambdaError() }, ) : RoomPreview { override suspend fun leave(): Result = simulateLongTask { declineInviteResult() @@ -31,5 +32,9 @@ class FakeRoomPreview( forgetRoomResult() } + override suspend fun membershipDetails(): Result = simulateLongTask { + roomMembershipDetails() + } + override fun close() = Unit } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt index 0613c76fa2..ab52880ef7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt @@ -27,11 +27,13 @@ fun aRoomPreview( info: RoomPreviewInfo = aRoomPreviewInfo(), declineInviteResult: () -> Result = { lambdaError() }, forgetRoomResult: () -> Result = { lambdaError() }, + roomMembershipDetails: () -> Result = { lambdaError() }, ) = FakeRoomPreview( sessionId = sessionId, info = info, declineInviteResult = declineInviteResult, forgetRoomResult = forgetRoomResult, + roomMembershipDetails = roomMembershipDetails, ) fun aRoomPreviewInfo(