From a73bcb71d53462ee6fdd2d8c06cfb789e8456d50 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 10 Jan 2025 09:52:02 +0100 Subject: [PATCH] feature(room preview): Add option to forget room, improve the room preview screen for banned rooms. Some internal refactoring was done too: - Remove RoomInfo.isPublic to only use JoinRule. - Also take into account restricted access rooms for previews. --- .../AcceptDeclineInvitePresenterTest.kt | 6 +- .../features/joinroom/impl/JoinRoomEvents.kt | 2 + .../features/joinroom/impl/JoinRoomNode.kt | 1 + .../joinroom/impl/JoinRoomPresenter.kt | 76 +++- .../features/joinroom/impl/JoinRoomState.kt | 49 ++- .../joinroom/impl/JoinRoomStateProvider.kt | 58 ++- .../features/joinroom/impl/JoinRoomView.kt | 402 +++++++++++------- .../features/joinroom/impl/di/ForgetRoom.kt | 28 ++ .../joinroom/impl/di/JoinRoomModule.kt | 2 + .../impl/src/main/res/values/localazy.xml | 2 + .../features/joinroom/impl/FakeForgetRoom.kt | 20 + .../joinroom/impl/JoinRoomPresenterTest.kt | 226 ++++++++-- .../joinroom/impl/JoinRoomViewTest.kt | 37 +- .../suggestions/SuggestionsPickerView.kt | 1 + .../impl/RoomAliasResolverEvents.kt | 1 + .../impl/RoomAliasResolverPresenter.kt | 3 +- .../impl/RoomAliasResolverState.kt | 4 + .../impl/RoomAliasResolverStateProvider.kt | 5 +- .../impl/RoomAliasResolverView.kt | 109 +++-- .../impl/src/main/res/values/localazy.xml | 1 + .../impl/RoomDetailsStateProvider.kt | 2 + .../members/RoomMemberListStateProvider.kt | 2 + .../roomdetails/impl/MatrixRoomFixture.kt | 1 - .../impl/RoomDetailsPresenterTest.kt | 1 - .../RoomMembersModerationPresenterTest.kt | 2 +- .../impl/model/RoomListRoomSummaryProvider.kt | 1 + .../impl/src/main/res/values/localazy.xml | 8 + .../libraries/matrix/api/MatrixClient.kt | 4 +- .../matrix/api/room/MatrixRoomInfo.kt | 1 - .../libraries/matrix/api/room/RoomMember.kt | 1 + .../room/{PendingRoom.kt => RoomPreview.kt} | 9 +- .../api/room/preview/RoomPreviewInfo.kt | 14 +- .../libraries/matrix/impl/RustMatrixClient.kt | 16 +- .../matrix/impl/room/MatrixRoomInfoMapper.kt | 1 - .../matrix/impl/room/RustRoomFactory.kt | 14 +- ...{RustPendingRoom.kt => RustRoomPreview.kt} | 19 +- .../impl/room/member/RoomMemberMapper.kt | 1 + .../room/preview/RoomPreviewInfoMapper.kt | 9 +- .../impl/room/MatrixRoomInfoMapperTest.kt | 2 - .../room/preview/RoomPreviewInfoMapperTest.kt | 20 +- .../libraries/matrix/test/FakeMatrixClient.kt | 8 +- ...{FakePendingRoom.kt => FakeRoomPreview.kt} | 13 +- .../matrix/test/room/RoomInfoFixture.kt | 2 - .../matrix/test/room/RoomMemberFixture.kt | 2 + .../test/room/RoomPreviewInfoFixture.kt | 43 ++ .../matrix/test/room/RoomSummaryFixture.kt | 2 - .../matrix/ui/components/InviteSenderView.kt | 5 +- .../libraries/matrix/ui/model/InviteSender.kt | 2 + .../src/main/res/values/localazy.xml | 2 + tools/localazy/config.json | 3 +- 50 files changed, 886 insertions(+), 357 deletions(-) create mode 100644 features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt create mode 100644 features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt rename libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/{PendingRoom.kt => RoomPreview.kt} (68%) rename libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/{RustPendingRoom.kt => RustRoomPreview.kt} (62%) rename libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/{FakePendingRoom.kt => FakeRoomPreview.kt} (75%) create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt index 4b064e0108..3c4b014e21 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt @@ -22,7 +22,7 @@ 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_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakePendingRoom +import io.element.android.libraries.matrix.test.room.FakeRoomPreview import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner @@ -78,7 +78,7 @@ class AcceptDeclineInvitePresenterTest { Result.failure(RuntimeException("Failed to leave room")) } val client = FakeMatrixClient().apply { - getPendingRoomResults[A_ROOM_ID] = FakePendingRoom(declineInviteResult = declineInviteFailure) + getRoomPreviewResults[A_ROOM_ID] = FakeRoomPreview(declineInviteResult = declineInviteFailure) } val presenter = createAcceptDeclineInvitePresenter(client = client) presenter.test { @@ -121,7 +121,7 @@ class AcceptDeclineInvitePresenterTest { Result.success(Unit) } val client = FakeMatrixClient().apply { - getPendingRoomResults[A_ROOM_ID] = FakePendingRoom(declineInviteResult = declineInviteSuccess) + getRoomPreviewResults[A_ROOM_ID] = FakeRoomPreview(declineInviteResult = declineInviteSuccess) } val presenter = createAcceptDeclineInvitePresenter( client = client, diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt index 5550d06bac..3e8cebb8c1 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomEvents.kt @@ -9,8 +9,10 @@ package io.element.android.features.joinroom.impl sealed interface JoinRoomEvents { data object RetryFetchingContent : JoinRoomEvents + data object DismissContent : JoinRoomEvents data object JoinRoom : JoinRoomEvents data object KnockRoom : JoinRoomEvents + data object ForgetRoom : JoinRoomEvents data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvents data class UpdateKnockMessage(val message: String) : JoinRoomEvents data object ClearActionStates : JoinRoomEvents diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt index ec910ee29d..dbf741f2d8 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt @@ -43,6 +43,7 @@ class JoinRoomNode @AssistedInject constructor( state = state, onBackClick = ::navigateUp, onJoinSuccess = ::navigateUp, + onForgetSuccess = ::navigateUp, onCancelKnockSuccess = {}, onKnockSuccess = {}, modifier = modifier 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 3cb7b5f82a..a0b5b5f6ae 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 @@ -26,22 +26,27 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.InviteData import io.element.android.features.joinroom.impl.di.CancelKnockRoom +import io.element.android.features.joinroom.impl.di.ForgetRoom import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState +import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.meta.BuildMeta 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.RoomIdOrAlias 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.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.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.ui.model.toInviteSender import kotlinx.coroutines.CoroutineScope @@ -58,6 +63,7 @@ class JoinRoomPresenter @AssistedInject constructor( private val joinRoom: JoinRoom, private val knockRoom: KnockRoom, private val cancelKnockRoom: CancelKnockRoom, + private val forgetRoom: ForgetRoom, private val acceptDeclineInvitePresenter: Presenter, private val buildMeta: BuildMeta, ) : Presenter { @@ -79,13 +85,17 @@ class JoinRoomPresenter @AssistedInject constructor( val joinAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val cancelKnockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val forgetRoomAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } var knockMessage by rememberSaveable { mutableStateOf("") } + var isDismissingContent by remember { mutableStateOf(false) } val contentState by produceState( - initialValue = ContentState.Loading(roomIdOrAlias), + initialValue = ContentState.Loading, key1 = roomInfo, key2 = retryCount, + key3 = isDismissingContent, ) { when { + isDismissingContent -> value = ContentState.Dismissing roomInfo.isPresent -> { value = roomInfo.get().toContentState() } @@ -93,17 +103,17 @@ class JoinRoomPresenter @AssistedInject constructor( value = roomDescription.get().toContentState() } else -> { - value = ContentState.Loading(roomIdOrAlias) + value = ContentState.Loading val result = matrixClient.getRoomPreviewInfo(roomIdOrAlias, serverNames) value = result.fold( onSuccess = { previewInfo -> previewInfo.toContentState() }, onFailure = { throwable -> - if (throwable.message?.contains("403") == true) { - ContentState.UnknownRoom(roomIdOrAlias) + if (throwable is ClientException.MatrixApi && (throwable.kind == ErrorKind.NotFound || throwable.kind == ErrorKind.Forbidden)) { + ContentState.UnknownRoom } else { - ContentState.Failure(roomIdOrAlias, throwable) + ContentState.Failure(throwable) } } ) @@ -136,18 +146,25 @@ class JoinRoomPresenter @AssistedInject constructor( knockAction.value = AsyncAction.Uninitialized joinAction.value = AsyncAction.Uninitialized cancelKnockAction.value = AsyncAction.Uninitialized + forgetRoomAction.value = AsyncAction.Uninitialized } is JoinRoomEvents.UpdateKnockMessage -> { knockMessage = event.message.take(MAX_KNOCK_MESSAGE_LENGTH) } + JoinRoomEvents.DismissContent -> { + isDismissingContent = true + } + JoinRoomEvents.ForgetRoom -> coroutineScope.forgetRoom(forgetRoomAction) } } return JoinRoomState( + roomIdOrAlias = roomIdOrAlias, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, joinAction = joinAction.value, knockAction = knockAction.value, + forgetAction = forgetRoomAction.value, cancelKnockAction = cancelKnockAction.value, applicationName = buildMeta.applicationName, knockMessage = knockMessage, @@ -161,7 +178,13 @@ class JoinRoomPresenter @AssistedInject constructor( roomIdOrAlias = roomIdOrAlias, serverNames = serverNames, trigger = trigger - ) + ).mapFailure { + if (it is ClientException.MatrixApi && it.kind == ErrorKind.Forbidden) { + JoinRoomFailures.UnauthorizedJoin + } else { + it + } + } } } @@ -180,6 +203,12 @@ class JoinRoomPresenter @AssistedInject constructor( } } } + + private fun CoroutineScope.forgetRoom(forgetAction: MutableState>) = launch { + forgetAction.runUpdatingState { + forgetRoom.invoke(roomId) + } + } } private fun RoomPreviewInfo.toContentState(): ContentState { @@ -192,12 +221,11 @@ private fun RoomPreviewInfo.toContentState(): ContentState { isDm = false, roomType = roomType, roomAvatarUrl = avatarUrl, - joinAuthorisationStatus = when { - // Note when isInvited, roomInfo will be used, so if this happen, it will be temporary. - isInvited -> JoinAuthorisationStatus.IsInvited(null) - canKnock -> JoinAuthorisationStatus.CanKnock - isPublic -> JoinAuthorisationStatus.CanJoin - else -> JoinAuthorisationStatus.Unknown + joinAuthorisationStatus = when (membership) { + CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(null) + CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(null) + CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked + else -> joinRule.toJoinAuthorisationStatus() } ) } @@ -232,17 +260,31 @@ internal fun MatrixRoomInfo.toContentState(): ContentState { isDm = isDm, roomType = if (isSpace) RoomType.Space else RoomType.Room, roomAvatarUrl = avatarUrl, - joinAuthorisationStatus = when { - currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited( + joinAuthorisationStatus = when (currentUserMembership) { + CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited( inviteSender = inviter?.toInviteSender() ) - currentUserMembership == CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked - isPublic -> JoinAuthorisationStatus.CanJoin - else -> JoinAuthorisationStatus.Unknown + CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned( + banSender = inviter?.toInviteSender() + ) + CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked + else -> joinRule.toJoinAuthorisationStatus() } ) } +private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus { + return when (this) { + JoinRule.Knock, + is JoinRule.KnockRestricted -> JoinAuthorisationStatus.CanKnock + JoinRule.Invite, + JoinRule.Private -> JoinAuthorisationStatus.NeedInvite + is JoinRule.Restricted -> JoinAuthorisationStatus.Restricted + JoinRule.Public -> JoinAuthorisationStatus.CanJoin + else -> JoinAuthorisationStatus.Unknown + } +} + @VisibleForTesting internal fun ContentState.toInviteData(): InviteData? { return when (this) { 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 85538d6d88..27ea7ad3f0 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 @@ -22,30 +22,49 @@ internal const val MAX_KNOCK_MESSAGE_LENGTH = 500 @Immutable data class JoinRoomState( + val roomIdOrAlias: RoomIdOrAlias, val contentState: ContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, val joinAction: AsyncAction, val knockAction: AsyncAction, + val forgetAction: AsyncAction, val cancelKnockAction: AsyncAction, - val applicationName: String, + private val applicationName: String, val knockMessage: String, val eventSink: (JoinRoomEvents) -> Unit ) { + val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoomFailures.UnauthorizedJoin val joinAuthorisationStatus = when (contentState) { - // Use the join authorisation status from the loaded content state - is ContentState.Loaded -> contentState.joinAuthorisationStatus - // Assume that if the room is unknown, the user can join it - is ContentState.UnknownRoom -> JoinAuthorisationStatus.CanJoin - // Otherwise assume that the user can't join the room - else -> JoinAuthorisationStatus.Unknown + is ContentState.Loaded -> { + when { + contentState.roomType == RoomType.Space -> { + JoinAuthorisationStatus.IsSpace(applicationName) + } + isJoinActionUnauthorized -> { + JoinAuthorisationStatus.Unauthorized + } + else -> { + contentState.joinAuthorisationStatus + } + } + } + is ContentState.UnknownRoom -> { + if (isJoinActionUnauthorized) { + JoinAuthorisationStatus.Unauthorized + } else { + JoinAuthorisationStatus.Unknown + } + } + else -> JoinAuthorisationStatus.None } } @Immutable sealed interface ContentState { - data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState - data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState - data class UnknownRoom(val roomIdOrAlias: RoomIdOrAlias) : ContentState + data object Dismissing : ContentState + data object Loading : ContentState + data class Failure(val error: Throwable) : ContentState + data object UnknownRoom : ContentState data class Loaded( val roomId: RoomId, val name: String?, @@ -71,9 +90,19 @@ sealed interface ContentState { } 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 object IsKnocked : JoinAuthorisationStatus data object CanKnock : JoinAuthorisationStatus data object CanJoin : JoinAuthorisationStatus + data object NeedInvite : JoinAuthorisationStatus + data object Restricted : JoinAuthorisationStatus data object Unknown : JoinAuthorisationStatus + data object Unauthorized : JoinAuthorisationStatus +} + +sealed class JoinRoomFailures : Exception() { + data object UnauthorizedJoin : JoinRoomFailures() } 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 d34d377394..30fcacb7c2 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 @@ -18,6 +18,7 @@ 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.core.UserId 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.ui.model.InviteSender @@ -25,10 +26,10 @@ open class JoinRoomStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aJoinRoomState( - contentState = aLoadingContentState() + contentState = ContentState.Loading ), aJoinRoomState( - contentState = anUnknownContentState() + contentState = ContentState.UnknownRoom ), aJoinRoomState( contentState = aLoadedContentState( @@ -40,6 +41,14 @@ open class JoinRoomStateProvider : PreviewParameterProvider { aJoinRoomState( contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), + joinAction = AsyncAction.Failure(JoinRoomFailures.UnauthorizedJoin) + ), + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin), + joinAction = AsyncAction.Failure(ClientException.Generic("Something went wrong")) + ), aJoinRoomState( contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)) ), @@ -52,9 +61,6 @@ open class JoinRoomStateProvider : PreviewParameterProvider { aJoinRoomState( contentState = aFailureContentState() ), - aJoinRoomState( - contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias()) - ), aJoinRoomState( contentState = aLoadedContentState( roomId = RoomId("!aSpaceId:domain"), @@ -98,23 +104,41 @@ open class JoinRoomStateProvider : PreviewParameterProvider { name = "A knocked Room", joinAuthorisationStatus = JoinAuthorisationStatus.IsKnocked ) - ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A private room", + joinAuthorisationStatus = JoinAuthorisationStatus.NeedInvite + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A banned room", + joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned( + InviteSender( + userId = UserId("@alice:domain"), + displayName = "Alice", + avatarData = AvatarData("alice", "Alice", size = AvatarSize.InviteSender), + membershipChangeReason = "spamming" + ) + ), + ) + ), + aJoinRoomState( + contentState = aLoadedContentState( + name = "A restricted room", + joinAuthorisationStatus = JoinAuthorisationStatus.Restricted, + ) + ), ) } -fun aFailureContentState( - roomIdOrAlias: RoomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias() -): ContentState { +fun aFailureContentState(): ContentState { return ContentState.Failure( - roomIdOrAlias = roomIdOrAlias, error = Exception("Error"), ) } -fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId.toRoomIdOrAlias()) - -fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId.toRoomIdOrAlias()) - fun aLoadedContentState( roomId: RoomId = A_ROOM_ID, name: String? = "Element X android", @@ -138,19 +162,23 @@ fun aLoadedContentState( ) fun aJoinRoomState( + roomIdOrAlias: RoomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias(), contentState: ContentState = aLoadedContentState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), joinAction: AsyncAction = AsyncAction.Uninitialized, knockAction: AsyncAction = AsyncAction.Uninitialized, + forgetAction: AsyncAction = AsyncAction.Uninitialized, cancelKnockAction: AsyncAction = AsyncAction.Uninitialized, knockMessage: String = "", eventSink: (JoinRoomEvents) -> Unit = {} ) = JoinRoomState( + roomIdOrAlias = roomIdOrAlias, contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, joinAction = joinAction, knockAction = knockAction, cancelKnockAction = cancelKnockAction, + forgetAction = forgetAction, applicationName = "AppName", knockMessage = knockMessage, eventSink = eventSink @@ -160,10 +188,12 @@ internal fun anInviteSender( userId: UserId = UserId("@bob:domain"), displayName: String = "Bob", avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender), + membershipChangeReason: String? = null, ) = InviteSender( userId = userId, displayName = displayName, avatarData = avatarData, + membershipChangeReason = membershipChangeReason, ) private val A_ROOM_ID = RoomId("!exa:matrix.org") 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 88a0c15ecf..753539d947 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,7 +32,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -46,7 +46,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubti import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage -import io.element.android.libraries.designsystem.background.LightGradientBackground +import io.element.android.libraries.designsystem.components.Announcement +import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -54,16 +55,17 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.SuperButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.core.RoomIdOrAlias -import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.ui.components.InviteSenderView import io.element.android.libraries.ui.strings.CommonStrings @@ -73,30 +75,33 @@ fun JoinRoomView( onBackClick: () -> Unit, onJoinSuccess: () -> Unit, onKnockSuccess: () -> Unit, + onForgetSuccess: () -> Unit, onCancelKnockSuccess: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier.fillMaxSize(), ) { - LightGradientBackground() HeaderFooterPage( containerColor = Color.Transparent, - paddingValues = PaddingValues(16.dp), + paddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 32.dp + ), topBar = { JoinRoomTopBar(contentState = state.contentState, onBackClick = onBackClick) }, content = { JoinRoomContent( + roomIdOrAlias = state.roomIdOrAlias, contentState = state.contentState, - applicationName = state.applicationName, knockMessage = state.knockMessage, onKnockMessageUpdate = { state.eventSink(JoinRoomEvents.UpdateKnockMessage(it)) }, ) }, footer = { JoinRoomFooter( - state = state, + joinAuthorisationStatus = state.joinAuthorisationStatus, onAcceptInvite = { state.eventSink(JoinRoomEvents.AcceptInvite) }, @@ -112,31 +117,55 @@ fun JoinRoomView( onCancelKnock = { state.eventSink(JoinRoomEvents.CancelKnock(requiresConfirmation = true)) }, - onRetry = { - state.eventSink(JoinRoomEvents.RetryFetchingContent) + onForgetRoom = { + state.eventSink(JoinRoomEvents.ForgetRoom) }, onGoBack = onBackClick, ) } ) } + if (state.contentState is ContentState.Failure) { + RetryDialog( + title = stringResource(R.string.screen_join_room_loading_alert_title), + content = stringResource(CommonStrings.error_network_or_server_issue), + onRetry = { state.eventSink(JoinRoomEvents.RetryFetchingContent) }, + onDismiss = { + state.eventSink(JoinRoomEvents.DismissContent) + onBackClick() + } + ) + } + // This particular error is shown directly in the footer + if (!state.isJoinActionUnauthorized) { + AsyncActionView( + async = state.joinAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onJoinSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, + ) + } AsyncActionView( - async = state.joinAction, - onSuccess = { onJoinSuccess() }, + async = state.knockAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onKnockSuccess() }, onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, ) AsyncActionView( - async = state.knockAction, - onSuccess = { onKnockSuccess() }, + async = state.forgetAction, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, + onSuccess = { onForgetSuccess() }, onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, ) AsyncActionView( async = state.cancelKnockAction, onSuccess = { onCancelKnockSuccess() }, onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearActionStates) }, - errorMessage = { - stringResource(CommonStrings.error_unknown) - }, + errorTitle = { stringResource(CommonStrings.common_something_went_wrong) }, + errorMessage = { stringResource(CommonStrings.error_network_or_server_issue) }, confirmationDialog = { ConfirmationDialog( content = stringResource(R.string.screen_join_room_cancel_knock_alert_description), @@ -152,13 +181,13 @@ fun JoinRoomView( @Composable private fun JoinRoomFooter( - state: JoinRoomState, + joinAuthorisationStatus: JoinAuthorisationStatus, onAcceptInvite: () -> Unit, onDeclineInvite: () -> Unit, onJoinRoom: () -> Unit, onKnockRoom: () -> Unit, onCancelKnock: () -> Unit, - onRetry: () -> Unit, + onForgetRoom: () -> Unit, onGoBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -167,79 +196,170 @@ private fun JoinRoomFooter( .fillMaxWidth() .padding(top = 8.dp) ) { - if (state.contentState is ContentState.Failure) { - Button( - text = stringResource(CommonStrings.action_retry), - onClick = onRetry, - modifier = Modifier.fillMaxWidth(), - size = ButtonSize.Large, - ) - } else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) { - Button( - text = stringResource(CommonStrings.action_go_back), - onClick = onGoBack, - modifier = Modifier.fillMaxWidth(), - size = ButtonSize.Large, - ) - } else { - val joinAuthorisationStatus = state.joinAuthorisationStatus - when (joinAuthorisationStatus) { - is JoinAuthorisationStatus.IsInvited -> { - ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) { - OutlinedButton( - text = stringResource(CommonStrings.action_decline), - onClick = onDeclineInvite, - modifier = Modifier.weight(1f), - size = ButtonSize.LargeLowPadding, - ) - Button( - text = stringResource(CommonStrings.action_accept), - onClick = onAcceptInvite, - modifier = Modifier.weight(1f), - size = ButtonSize.LargeLowPadding, - ) - } - } - JoinAuthorisationStatus.CanJoin -> { - SuperButton( - onClick = onJoinRoom, - modifier = Modifier.fillMaxWidth(), - buttonSize = ButtonSize.Large, - ) { - Text( - text = stringResource(R.string.screen_join_room_join_action), - ) - } - } - JoinAuthorisationStatus.CanKnock -> { - SuperButton( - onClick = onKnockRoom, - modifier = Modifier.fillMaxWidth(), - buttonSize = ButtonSize.Large, - ) { - Text( - text = stringResource(R.string.screen_join_room_knock_action), - ) - } - } - JoinAuthorisationStatus.IsKnocked -> { + when (joinAuthorisationStatus) { + is JoinAuthorisationStatus.IsInvited -> { + ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) { OutlinedButton( - text = stringResource(R.string.screen_join_room_cancel_knock_action), - onClick = onCancelKnock, - modifier = Modifier.fillMaxWidth(), - size = ButtonSize.Large, + text = stringResource(CommonStrings.action_decline), + onClick = onDeclineInvite, + modifier = Modifier.weight(1f), + size = ButtonSize.LargeLowPadding, + ) + Button( + text = stringResource(CommonStrings.action_accept), + onClick = onAcceptInvite, + modifier = Modifier.weight(1f), + size = ButtonSize.LargeLowPadding, ) } - JoinAuthorisationStatus.Unknown -> Unit } + JoinAuthorisationStatus.CanJoin -> { + SuperButton( + onClick = onJoinRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_join_action), + ) + } + } + JoinAuthorisationStatus.CanKnock -> { + SuperButton( + onClick = onKnockRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_knock_action), + ) + } + } + JoinAuthorisationStatus.IsKnocked -> { + OutlinedButton( + text = stringResource(R.string.screen_join_room_cancel_knock_action), + onClick = onCancelKnock, + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.Large, + ) + } + JoinAuthorisationStatus.NeedInvite -> { + Announcement( + title = stringResource(R.string.screen_join_room_invite_required_message), + description = null, + type = AnnouncementType.Informative(isCritical = false), + ) + } + is JoinAuthorisationStatus.IsBanned -> JoinBannedFooter(joinAuthorisationStatus, onForgetRoom) + JoinAuthorisationStatus.Unknown -> JoinRestrictedFooter(onJoinRoom) + JoinAuthorisationStatus.Restricted -> JoinRestrictedFooter(onJoinRoom) + JoinAuthorisationStatus.Unauthorized -> JoinUnauthorizedFooter(onGoBack) + is JoinAuthorisationStatus.IsSpace -> UnsupportedSpaceFooter(joinAuthorisationStatus.applicationName, onGoBack) + JoinAuthorisationStatus.None -> Unit } } } @Composable -private fun JoinRoomContent( - contentState: ContentState, +private fun JoinUnauthorizedFooter( + onOkClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Announcement( + title = stringResource(R.string.screen_join_room_fail_message), + description = stringResource(R.string.screen_join_room_fail_reason), + type = AnnouncementType.Informative(isCritical = true), + ) + Spacer(Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_ok), + onClick = onOkClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun JoinBannedFooter( + status: JoinAuthorisationStatus.IsBanned, + onForgetRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + val banReason = status.banSender?.membershipChangeReason?.let { + stringResource(R.string.screen_join_room_ban_reason, it) + } + val title = if (status.banSender != null) { + stringResource(R.string.screen_join_room_ban_by_message, status.banSender.displayName) + } else { + stringResource(R.string.screen_join_room_ban_message) + } + Announcement( + title = title, + description = banReason, + type = AnnouncementType.Informative(isCritical = true), + ) + Spacer(Modifier.height(24.dp)) + Button( + text = stringResource(R.string.screen_join_room_forget_action), + onClick = onForgetRoom, + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.Large, + ) + } +} + +@Composable +private fun JoinRestrictedFooter( + onJoinRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Announcement( + title = stringResource(R.string.screen_join_room_join_restricted_message), + description = null, + type = AnnouncementType.Informative(), + ) + Spacer(Modifier.height(24.dp)) + SuperButton( + onClick = onJoinRoom, + modifier = Modifier.fillMaxWidth(), + buttonSize = ButtonSize.Large, + ) { + Text( + text = stringResource(R.string.screen_join_room_join_action), + ) + } + } +} + +@Composable +private fun UnsupportedSpaceFooter( applicationName: String, + onGoBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Announcement( + title = stringResource(R.string.screen_join_room_space_not_supported_title), + description = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName), + type = AnnouncementType.Informative(), + ) + Spacer(Modifier.height(24.dp)) + Button( + text = stringResource(CommonStrings.action_ok), + onClick = onGoBack, + modifier = Modifier.fillMaxWidth(), + size = ButtonSize.Large, + ) + } +} + +@Composable +private fun JoinRoomContent( + roomIdOrAlias: RoomIdOrAlias, + contentState: ContentState, knockMessage: String, onKnockMessageUpdate: (String) -> Unit, modifier: Modifier = Modifier, @@ -255,67 +375,67 @@ private fun JoinRoomContent( DefaultLoadedContent( modifier = Modifier.verticalScroll(rememberScrollState()), contentState = contentState, - applicationName = applicationName, knockMessage = knockMessage, onKnockMessageUpdate = onKnockMessageUpdate ) } } } - is ContentState.UnknownRoom -> { - RoomPreviewOrganism( - avatar = { - PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) - }, - title = { - RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview)) - }, - subtitle = { - RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview)) - }, - ) - } - is ContentState.Loading -> { - RoomPreviewOrganism( - avatar = { - PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) - }, - title = { - PlaceholderAtom(width = 200.dp, height = 22.dp) - }, - subtitle = { - PlaceholderAtom(width = 140.dp, height = 20.dp) - }, - ) - } - is ContentState.Failure -> { - RoomPreviewOrganism( - avatar = { - PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) - }, - title = { - when (contentState.roomIdOrAlias) { - is RoomIdOrAlias.Alias -> { - RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier) - } - is RoomIdOrAlias.Id -> { - PlaceholderAtom(width = 200.dp, height = 22.dp) - } - } - }, - subtitle = { - Text( - text = stringResource(id = CommonStrings.error_unknown), - textAlign = TextAlign.Center, - color = ElementTheme.colors.textCriticalPrimary, - ) - }, - ) - } + is ContentState.UnknownRoom -> UnknownRoomContent() + is ContentState.Loading -> IncompleteContent(roomIdOrAlias, isLoading = true) + is ContentState.Dismissing -> IncompleteContent(roomIdOrAlias, isLoading = false) + is ContentState.Failure -> IncompleteContent(roomIdOrAlias, isLoading = false) } } } +@Composable +private fun UnknownRoomContent( + modifier: Modifier = Modifier +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + Spacer(modifier = Modifier.size(AvatarSize.RoomHeader.dp)) + }, + title = { + RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview)) + }, + subtitle = { + }, + ) +} + +@Composable +private fun IncompleteContent( + roomIdOrAlias: RoomIdOrAlias, + isLoading: Boolean, + modifier: Modifier = Modifier +) { + RoomPreviewOrganism( + modifier = modifier, + avatar = { + PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) + }, + title = { + when (roomIdOrAlias) { + is RoomIdOrAlias.Alias -> { + RoomPreviewSubtitleAtom(roomIdOrAlias.identifier) + } + is RoomIdOrAlias.Id -> { + PlaceholderAtom(width = 200.dp, height = 22.dp) + } + } + }, + subtitle = { + if (isLoading) { + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() + } + }, + ) +} + @Composable private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) { BoxWithConstraints( @@ -336,7 +456,6 @@ private fun IsKnockedLoadedContent(modifier: Modifier = Modifier) { @Composable private fun DefaultLoadedContent( contentState: ContentState.Loaded, - applicationName: String, knockMessage: String, onKnockMessageUpdate: (String) -> Unit, modifier: Modifier = Modifier, @@ -373,21 +492,7 @@ private fun DefaultLoadedContent( InviteSenderView(inviteSender = inviteSender) } RoomPreviewDescriptionAtom(contentState.topic ?: "") - if (contentState.roomType == RoomType.Space) { - Spacer(modifier = Modifier.height(24.dp)) - Text( - text = stringResource(R.string.screen_join_room_space_not_supported_title), - textAlign = TextAlign.Center, - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - ) - Text( - text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName), - textAlign = TextAlign.Center, - style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, - ) - } else if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) { + if (contentState.joinAuthorisationStatus is JoinAuthorisationStatus.CanKnock) { Spacer(modifier = Modifier.height(24.dp)) val supportingText = if (knockMessage.isNotEmpty()) { "${knockMessage.length}/$MAX_KNOCK_MESSAGE_LENGTH" @@ -461,6 +566,7 @@ internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) onBackClick = { }, onJoinSuccess = { }, onKnockSuccess = { }, + onForgetSuccess = { }, onCancelKnockSuccess = { }, ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt new file mode 100644 index 0000000000..496a911b1b --- /dev/null +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/ForgetRoom.kt @@ -0,0 +1,28 @@ +/* + * 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.joinroom.impl.di + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import javax.inject.Inject + +interface ForgetRoom { + suspend operator fun invoke(roomId: RoomId): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultForgetRoom @Inject constructor(private val client: MatrixClient) : ForgetRoom { + override suspend fun invoke(roomId: RoomId): Result { + return client + .getPendingRoom(roomId) + ?.forget() + ?: Result.failure(IllegalStateException("No pending room found")) + } +} diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt index d906edbba9..ff4cbbbc80 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -32,6 +32,7 @@ object JoinRoomModule { joinRoom: JoinRoom, knockRoom: KnockRoom, cancelKnockRoom: CancelKnockRoom, + forgetRoom: ForgetRoom, acceptDeclineInvitePresenter: Presenter, buildMeta: BuildMeta, ): JoinRoomPresenter.Factory { @@ -52,6 +53,7 @@ object JoinRoomModule { matrixClient = client, joinRoom = joinRoom, knockRoom = knockRoom, + forgetRoom = forgetRoom, cancelKnockRoom = cancelKnockRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, buildMeta = buildMeta, diff --git a/features/joinroom/impl/src/main/res/values/localazy.xml b/features/joinroom/impl/src/main/res/values/localazy.xml index 391d63eb2f..22a1abeacc 100644 --- a/features/joinroom/impl/src/main/res/values/localazy.xml +++ b/features/joinroom/impl/src/main/res/values/localazy.xml @@ -17,6 +17,8 @@ "Message (optional)" "You will receive an invite to join the room if your request is accepted." "Request to join sent" + "We could not display the room preview. This may be due to network or server issues." + "We couldn’t display this room preview" "%1$s does not support spaces yet. You can access spaces on web." "Spaces are not supported yet" "Click the button below and a room administrator will be notified. You’ll be able to join the conversation once approved." diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.kt new file mode 100644 index 0000000000..48d3eec077 --- /dev/null +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/FakeForgetRoom.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.features.joinroom.impl + +import io.element.android.features.joinroom.impl.di.ForgetRoom +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.tests.testutils.simulateLongTask + +class FakeForgetRoom( + var lambda: (RoomId) -> Result = { Result.success(Unit) } +) : ForgetRoom { + override suspend fun invoke(roomId: RoomId) = simulateLongTask { + lambda(roomId) + } +} 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 82c4d4f637..2d615ff034 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 @@ -13,6 +13,7 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.joinroom.impl.di.CancelKnockRoom +import io.element.android.features.joinroom.impl.di.ForgetRoom import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.AsyncAction @@ -24,9 +25,11 @@ 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.core.UserId 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.RoomType -import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +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 @@ -34,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_SERVER_LIST 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.aRoomPreviewInfo 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.ui.model.toInviteSender @@ -49,6 +53,7 @@ import org.junit.Rule import org.junit.Test import java.util.Optional +@Suppress("LargeClass") class JoinRoomPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -58,12 +63,10 @@ class JoinRoomPresenterTest { val presenter = createJoinRoomPresenter() presenter.test { awaitItem().also { state -> - assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias())) - assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + assertThat(state.contentState).isEqualTo(ContentState.Loading) assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.cancelKnockAction).isEqualTo(AsyncAction.Uninitialized) assertThat(state.knockAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(state.applicationName).isEqualTo("AppName") cancelAndIgnoreRemainingEvents() } } @@ -226,9 +229,52 @@ class JoinRoomPresenterTest { } } + @Test + fun `present - when room is joined with unauthorized error, then the authorisation status is unauthorized`() = runTest { + val roomDescription = aRoomDescription() + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription), + joinRoomLambda = { _, _, _ -> + Result.failure(ClientException.MatrixApi(ErrorKind.Forbidden, "403", "Forbidden")) + }, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(JoinRoomFailures.UnauthorizedJoin)) + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unauthorized) + } + } + } + + @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 { + getRoomSummaryFlowLambda = { _ -> + flowOf(Optional.of(roomSummary)) + } + } + val presenter = createJoinRoomPresenter( + matrixClient = matrixClient + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isInstanceOf(JoinAuthorisationStatus.IsBanned::class.java) + } + } + } + @Test fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest { - val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, isPublic = true) + val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = JoinRule.Public) val matrixClient = FakeMatrixClient().apply { getRoomSummaryFlowLambda = { _ -> flowOf(Optional.of(roomSummary)) @@ -246,8 +292,8 @@ class JoinRoomPresenterTest { } @Test - fun `present - when room is left and not public then join authorization is equal to unknown`() = runTest { - val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, isPublic = false) + fun `present - when room is left and join rule null then join authorization is equal to Unknown`() = runTest { + val roomSummary = aRoomSummary(currentUserMembership = CurrentUserMembership.LEFT, joinRule = null) val matrixClient = FakeMatrixClient().apply { getRoomSummaryFlowLambda = { _ -> flowOf(Optional.of(roomSummary)) @@ -327,6 +373,20 @@ class JoinRoomPresenterTest { } } + @Test + fun `present - when room preview join rule is Private then join authorization is equal to NeedInvite`() = runTest { + val roomDescription = aRoomDescription(joinRule = RoomDescription.JoinRule.UNKNOWN) + val presenter = createJoinRoomPresenter( + roomDescription = Optional.of(roomDescription) + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) + } + } + } + @Test fun `present - emit knock room event`() = runTest { val knockMessage = "Knock message" @@ -405,24 +465,58 @@ class JoinRoomPresenterTest { .with(value(A_ROOM_ID)) } + @Test + fun `present - emit forget room event`() = runTest { + val forgetRoomSuccess = lambdaRecorder { _: RoomId -> + Result.success(Unit) + } + val forgetRoomFailure = lambdaRecorder { _: RoomId -> + Result.failure(RuntimeException("Failed to forget room")) + } + val fakeForgetRoom = FakeForgetRoom(forgetRoomSuccess) + val presenter = createJoinRoomPresenter(forgetRoom = fakeForgetRoom) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.ForgetRoom) + } + + assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.forgetAction).isEqualTo(AsyncAction.Success(Unit)) + fakeForgetRoom.lambda = forgetRoomFailure + state.eventSink(JoinRoomEvents.ForgetRoom) + } + + assertThat(awaitItem().forgetAction).isEqualTo(AsyncAction.Loading) + awaitItem().also { state -> + assertThat(state.forgetAction).isInstanceOf(AsyncAction.Failure::class.java) + } + } + assert(forgetRoomFailure) + .isCalledOnce() + .with(value(A_ROOM_ID)) + assert(forgetRoomSuccess) + .isCalledOnce() + .with(value(A_ROOM_ID)) + } + @Test fun `present - when room is not known RoomPreview is loaded`() = runTest { val client = FakeMatrixClient( getRoomPreviewInfoResult = { _, _ -> Result.success( - RoomPreviewInfo( + aRoomPreviewInfo( roomId = A_ROOM_ID, canonicalAlias = RoomAlias("#alias:matrix.org"), name = "Room name", topic = "Room topic", avatarUrl = "avatarUrl", numberOfJoinedMembers = 2, - roomType = RoomType.Room, + isSpace = false, isHistoryWorldReadable = false, - isJoined = false, - isInvited = false, - isPublic = true, - canKnock = false, + joinRule = JoinRule.Public, + currentUserMembership = null, ) ) } @@ -450,6 +544,86 @@ class JoinRoomPresenterTest { } } + @Test + fun `present - when room is not known RoomPreview is loaded as Private `() = runTest { + val client = FakeMatrixClient( + getRoomPreviewInfoResult = { _, _ -> + Result.success( + aRoomPreviewInfo(joinRule = JoinRule.Private) + ) + } + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.NeedInvite) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as KnockRestricted `() = runTest { + val client = FakeMatrixClient( + getRoomPreviewInfoResult = { _, _ -> + Result.success( + aRoomPreviewInfo(joinRule = JoinRule.KnockRestricted(emptyList())) + ) + } + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.CanKnock) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Restricted `() = runTest { + val client = FakeMatrixClient( + getRoomPreviewInfoResult = { _, _ -> + Result.success( + aRoomPreviewInfo(joinRule = JoinRule.Restricted(emptyList())) + ) + } + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Restricted) + } + } + } + + @Test + fun `present - when room is not known RoomPreview is loaded as Space `() = runTest { + val client = FakeMatrixClient( + getRoomPreviewInfoResult = { _, _ -> + Result.success( + aRoomPreviewInfo(isSpace = true) + ) + } + ) + val presenter = createJoinRoomPresenter( + matrixClient = client + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsSpace("AppName")) + } + } + } + @Test fun `present - when room is not known RoomPreview is loaded with error`() = runTest { val client = FakeMatrixClient( @@ -464,35 +638,27 @@ class JoinRoomPresenterTest { skipItems(1) awaitItem().also { state -> assertThat(state.contentState).isEqualTo( - ContentState.Failure( - roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(), - error = AN_EXCEPTION - ) + ContentState.Failure(error = AN_EXCEPTION) ) state.eventSink(JoinRoomEvents.RetryFetchingContent) } skipItems(1) awaitItem().also { state -> - assertThat(state.contentState).isEqualTo( - ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()) - ) + assertThat(state.contentState).isEqualTo(ContentState.Loading) } awaitItem().also { state -> assertThat(state.contentState).isEqualTo( - ContentState.Failure( - roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(), - error = AN_EXCEPTION - ) + ContentState.Failure(error = AN_EXCEPTION) ) } } } @Test - fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest { + fun `present - when room is not known RoomPreview is loaded with error Forbidden`() = runTest { val client = FakeMatrixClient( getRoomPreviewInfoResult = { _, _ -> - Result.failure(Exception("403")) + Result.failure(ClientException.MatrixApi(ErrorKind.Forbidden, "403", "Forbidden")) } ) val presenter = createJoinRoomPresenter( @@ -501,11 +667,7 @@ class JoinRoomPresenterTest { presenter.test { skipItems(1) awaitItem().also { state -> - assertThat(state.contentState).isEqualTo( - ContentState.UnknownRoom( - roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(), - ) - ) + assertThat(state.contentState).isEqualTo(ContentState.UnknownRoom) } } } @@ -521,6 +683,7 @@ class JoinRoomPresenterTest { }, knockRoom: KnockRoom = FakeKnockRoom(), cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(), + forgetRoom: ForgetRoom = FakeForgetRoom(), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() } ): JoinRoomPresenter { @@ -534,6 +697,7 @@ class JoinRoomPresenterTest { joinRoom = FakeJoinRoom(joinRoomLambda), knockRoom = knockRoom, cancelKnockRoom = cancelKnockRoom, + forgetRoom = forgetRoom, buildMeta = buildMeta, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter ) 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 d756cdbb8c..1c8909a271 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 @@ -178,7 +178,7 @@ class JoinRoomViewTest { } @Test - fun `clicking on Go back when a space is displayed invokes the expected callback`() { + fun `clicking on ok when a space is displayed invokes the expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { rule.setJoinRoomView( @@ -188,9 +188,38 @@ class JoinRoomViewTest { ), onBackClick = it ) - rule.clickOn(CommonStrings.action_go_back) + rule.clickOn(CommonStrings.action_ok) } } + + @Test + fun `clicking on ok when user is unauthorized the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(), + joinAction = AsyncAction.Failure(JoinRoomFailures.UnauthorizedJoin), + eventSink = eventsRecorder, + ), + onBackClick = it + ) + rule.clickOn(CommonStrings.action_ok) + } + } + + @Test + fun `clicking on forget when user is banned invokes the expected callback`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsBanned(null)), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_join_room_forget_action) + eventsRecorder.assertSingle(JoinRoomEvents.ForgetRoom) + } } private fun AndroidComposeTestRule.setJoinRoomView( @@ -199,6 +228,7 @@ private fun AndroidComposeTestRule.setJoinR onJoinSuccess: () -> Unit = EnsureNeverCalled(), onKnockSuccess: () -> Unit = EnsureNeverCalled(), onCancelKnockSuccess: () -> Unit = EnsureNeverCalled(), + onForgetSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { JoinRoomView( @@ -206,7 +236,8 @@ private fun AndroidComposeTestRule.setJoinR onBackClick = onBackClick, onJoinSuccess = onJoinSuccess, onKnockSuccess = onKnockSuccess, - onCancelKnockSuccess = onCancelKnockSuccess + onForgetSuccess = onForgetSuccess, + onCancelKnockSuccess = onCancelKnockSuccess, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt index 12379163a6..9d774d5841 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt @@ -149,6 +149,7 @@ internal fun SuggestionsPickerViewPreview() { normalizedPowerLevel = 0L, isIgnored = false, role = RoomMember.Role.USER, + membershipChangeReason = null, ) val anAlias = remember { RoomAlias("#room:domain.org") } SuggestionsPickerView( diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt index b1faa6ef6d..34a96eaeb8 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverEvents.kt @@ -9,4 +9,5 @@ package io.element.android.features.roomaliasresolver.impl sealed interface RoomAliasResolverEvents { data object Retry : RoomAliasResolverEvents + data object DismissError : RoomAliasResolverEvents } diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt index 7391a35f41..34c655bd68 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -46,6 +46,7 @@ class RoomAliasResolverPresenter @AssistedInject constructor( fun handleEvents(event: RoomAliasResolverEvents) { when (event) { RoomAliasResolverEvents.Retry -> coroutineScope.resolveAlias(resolveState) + RoomAliasResolverEvents.DismissError -> resolveState.value = AsyncData.Uninitialized } } @@ -60,7 +61,7 @@ class RoomAliasResolverPresenter @AssistedInject constructor( suspend { matrixClient.resolveRoomAlias(roomAlias) .getOrThrow() - .getOrElse { error("Failed to resolve room alias $roomAlias") } + .getOrElse { throw RoomAliasResolverFailures.UnknownAlias } }.runCatchingUpdatingState(resolveState) } } diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt index d03b004027..dc858621f7 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt @@ -18,3 +18,7 @@ data class RoomAliasResolverState( val resolveState: AsyncData, val eventSink: (RoomAliasResolverEvents) -> Unit ) + +sealed class RoomAliasResolverFailures : Exception() { + data object UnknownAlias : RoomAliasResolverFailures() +} diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt index 933d4acdf1..d629089cf8 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.roomaliasresolver.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.RoomAlias +import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias open class RoomAliasResolverStateProvider : PreviewParameterProvider { @@ -17,10 +18,10 @@ open class RoomAliasResolverStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { - val latestOnSuccess by rememberUpdatedState(onSuccess) - LaunchedEffect(state.resolveState) { - if (state.resolveState is AsyncData.Success) { - latestOnSuccess(state.resolveState.data) - } - } Box( modifier = modifier.fillMaxSize(), ) { - LightGradientBackground() HeaderFooterPage( containerColor = Color.Transparent, - paddingValues = PaddingValues(16.dp), + paddingValues = PaddingValues( + horizontal = 16.dp, + vertical = 32.dp + ), topBar = { RoomAliasResolverTopBar(onBackClick = onBackClick) }, content = { - RoomAliasResolverContent(state = state) + RoomAliasResolverContent(roomAlias = state.roomAlias, isLoading = state.resolveState.isLoading()) }, - footer = { - RoomAliasResolverFooter( - state = state, - ) + ) + ResolvedRoomAliasView( + resolvedRoomAlias = state.resolveState, + onSuccess = onSuccess, + onRetry = { state.eventSink(RoomAliasResolverEvents.Retry) }, + onDismissError = { + state.eventSink(RoomAliasResolverEvents.DismissError) + onBackClick() } ) } } @Composable -private fun RoomAliasResolverFooter( - state: RoomAliasResolverState, - modifier: Modifier = Modifier, +private fun ResolvedRoomAliasView( + resolvedRoomAlias: AsyncData, + onSuccess: (ResolvedRoomAlias) -> Unit, + onRetry: () -> Unit, + onDismissError: () -> Unit, ) { - when (state.resolveState) { - is AsyncData.Failure -> { - Button( - text = stringResource(CommonStrings.action_retry), - onClick = { - state.eventSink(RoomAliasResolverEvents.Retry) - }, - modifier = modifier.fillMaxWidth(), - size = ButtonSize.Large, - ) - } - is AsyncData.Loading -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() + when (resolvedRoomAlias) { + is AsyncData.Success -> { + val latestOnSuccess by rememberUpdatedState(onSuccess) + LaunchedEffect(Unit) { + latestOnSuccess(resolvedRoomAlias.data) } } - AsyncData.Uninitialized, - is AsyncData.Success -> Unit + is AsyncData.Failure -> { + if (resolvedRoomAlias.error is RoomAliasResolverFailures.UnknownAlias) { + ErrorDialog( + title = stringResource(id = R.string.screen_join_room_loading_alert_title), + content = stringResource(id = R.string.screen_room_alias_resolver_resolve_alias_failure), + onSubmit = onDismissError + ) + } else { + RetryDialog( + title = stringResource(id = R.string.screen_join_room_loading_alert_title), + content = stringResource(id = CommonStrings.error_network_or_server_issue), + onRetry = onRetry, + onDismiss = onDismissError + ) + } + } + else -> Unit } } @Composable private fun RoomAliasResolverContent( - state: RoomAliasResolverState, + roomAlias: RoomAlias, + isLoading: Boolean, modifier: Modifier = Modifier, ) { RoomPreviewOrganism( @@ -118,20 +120,13 @@ private fun RoomAliasResolverContent( PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) }, title = { - RoomPreviewTitleAtom(state.roomAlias.value) + RoomPreviewSubtitleAtom(roomAlias.value) }, subtitle = { - }, - description = { - if (state.resolveState.isFailure()) { - Text( - text = stringResource(id = R.string.screen_room_alias_resolver_resolve_alias_failure), - textAlign = TextAlign.Center, - color = ElementTheme.colors.textCriticalPrimary, - ) + if (isLoading) { + Spacer(Modifier.height(8.dp)) + CircularProgressIndicator() } - }, - memberCount = { } ) } diff --git a/features/roomaliasresolver/impl/src/main/res/values/localazy.xml b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml index 21d5c17135..6ace0d7fb1 100644 --- a/features/roomaliasresolver/impl/src/main/res/values/localazy.xml +++ b/features/roomaliasresolver/impl/src/main/res/values/localazy.xml @@ -1,4 +1,5 @@ + "We couldn’t display this room preview" "Failed to resolve room alias." diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt index 59601f065d..597f9517b9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsStateProvider.kt @@ -64,6 +64,7 @@ fun aDmRoomMember( normalizedPowerLevel: Long = powerLevel, isIgnored: Boolean = false, role: RoomMember.Role = RoomMember.Role.USER, + membershipChangeReason: String? = null, ) = RoomMember( userId = userId, displayName = displayName, @@ -74,6 +75,7 @@ fun aDmRoomMember( normalizedPowerLevel = normalizedPowerLevel, isIgnored = isIgnored, role = role, + membershipChangeReason = membershipChangeReason ) fun aRoomDetailsState( diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index 499c8290b8..f917000154 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -126,6 +126,7 @@ fun aRoomMember( normalizedPowerLevel: Long = 0L, isIgnored: Boolean = false, role: RoomMember.Role = RoomMember.Role.USER, + membershipChangeReason: String? = null, ) = RoomMember( userId = userId, displayName = displayName, @@ -136,6 +137,7 @@ fun aRoomMember( normalizedPowerLevel = normalizedPowerLevel, isIgnored = isIgnored, role = role, + membershipChangeReason = membershipChangeReason, ) fun aRoomMemberList() = persistentListOf( diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt index 203b7adab5..d9cc77be76 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt @@ -73,7 +73,6 @@ fun aMatrixRoom( topic = topic, avatarUrl = avatarUrl, isDirect = isDirect, - isPublic = isPublic, joinRule = joinRule, ) ) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index ff612431c4..1eec78ea9c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -145,7 +145,6 @@ class RoomDetailsPresenterTest { fun `present - initial state is updated with roomInfo if it exists`() = runTest { val roomInfo = aRoomInfo( name = A_ROOM_NAME, - isPublic = true, topic = A_ROOM_TOPIC, avatarUrl = AN_AVATAR_URL, pinnedEventIds = listOf(AN_EVENT_ID), diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt index 530975f7a3..d569a93baa 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationPresenterTest.kt @@ -42,7 +42,7 @@ class RoomMembersModerationPresenterTest { canBanResult = { Result.success(true) }, userRoleResult = { Result.success(RoomMember.Role.ADMIN) }, ).apply { - givenRoomInfo(aRoomInfo(isDirect = true, isPublic = false, activeMembersCount = 2)) + givenRoomInfo(aRoomInfo(isDirect = true, activeMembersCount = 2)) } val presenter = createRoomMembersModerationPresenter(matrixRoom = room) presenter.test { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt index d293951f89..e71c8849b7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/model/RoomListRoomSummaryProvider.kt @@ -123,6 +123,7 @@ internal fun anInviteSender( userId = userId, displayName = displayName, avatarData = avatarData, + membershipChangeReason = null, ) internal fun aRoomListRoomSummary( diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index db18590cbe..df9e6284d5 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -13,9 +13,11 @@ "Something doesn’t seem right. Either the request timed out or the request was denied." "Confirm that the emojis below match those shown on your other session." "Compare emojis" + "Confirm that the emojis below match those shown on the other user’s device." "Confirm that the numbers below match those shown on your other session." "Compare numbers" "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted." + "Now you can trust the identity of this user when sending or receiving messages." "Enter recovery key" "Either the request timed out, the request was denied, or there was a verification mismatch." "Prove it’s you in order to access your encrypted message history." @@ -37,8 +39,14 @@ "They match" "Make sure you have the app open in the other device before starting verification from here." "Open the app on another verified device" + "For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate." + "Verify this user?" + "For extra security, another user wants to verify your identity. You’ll be shown a set of emojis to compare." "You should see a popup on the other device. Start the verification from there now." "Start verification on the other device" + "Waiting for the other device" + "Waiting for the other user" + "Once accepted you’ll be able to continue with the verification." "Accept the request to start the verification process in your other session to continue." "Waiting to accept request" "Signing out…" diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index a6cf5e5e70..59864562c8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -25,8 +25,8 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo -import io.element.android.libraries.matrix.api.room.PendingRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomPreview import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService @@ -55,7 +55,7 @@ interface MatrixClient : Closeable { val sessionCoroutineScope: CoroutineScope val ignoredUsersFlow: StateFlow> suspend fun getRoom(roomId: RoomId): MatrixRoom? - suspend fun getPendingRoom(roomId: RoomId): PendingRoom? + suspend fun getPendingRoom(roomId: RoomId): RoomPreview? suspend fun findDM(userId: UserId): RoomId? suspend fun ignoreUser(userId: UserId): Result suspend fun unignoreUser(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt index ff525c403d..32cc4646c0 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt @@ -28,7 +28,6 @@ data class MatrixRoomInfo( val topic: String?, val avatarUrl: String?, val isDirect: Boolean, - val isPublic: Boolean, val joinRule: JoinRule?, val isSpace: Boolean, val isTombstoned: Boolean, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index 758136c3a7..00da7e4137 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -20,6 +20,7 @@ data class RoomMember( val normalizedPowerLevel: Long, val isIgnored: Boolean, val role: Role, + val membershipChangeReason: String?, ) { /** * Role of the RoomMember, based on its [powerLevel]. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/PendingRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt similarity index 68% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/PendingRoom.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt index 3c7a6c6fe0..6722884f7f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/PendingRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomPreview.kt @@ -10,11 +10,16 @@ package io.element.android.libraries.matrix.api.room import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -/** A reference to a room the current user has knocked to or has been invited to, with the ability to leave the room. */ -interface PendingRoom : AutoCloseable { +/** A reference to a room either invited, knocked or banned. */ +interface RoomPreview : AutoCloseable { val sessionId: SessionId val roomId: RoomId /** Leave the room ie.decline invite or cancel knock. */ suspend fun leave(): Result + + /** + * Forget the room if we had access to it, and it was left or banned. + */ + suspend fun forget(): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt index e256cf7d0e..b00d59a3f6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/preview/RoomPreviewInfo.kt @@ -9,7 +9,9 @@ package io.element.android.libraries.matrix.api.room.preview import io.element.android.libraries.matrix.api.core.RoomAlias 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.room.join.JoinRule data class RoomPreviewInfo( /** The room id for this room. */ @@ -28,12 +30,8 @@ data class RoomPreviewInfo( val roomType: RoomType, /** Is the history world-readable for this room? */ val isHistoryWorldReadable: Boolean, - /** Is the room joined by the current user? */ - val isJoined: Boolean, - /** Is the current user invited to this room? */ - val isInvited: Boolean, - /** is the join rule public for this room? */ - val isPublic: Boolean, - /** Can we knock (or restricted-knock) to this room? */ - val canKnock: Boolean, + /** the membership of the current user. */ + val membership: CurrentUserMembership?, + /** The room's join rule. */ + val joinRule: JoinRule, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a37c57b16c..647b6ee6a2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.DeviceId @@ -32,9 +33,9 @@ import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.PendingRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomPreview import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo @@ -50,6 +51,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.impl.core.toProgressWatcher import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService +import io.element.android.libraries.matrix.impl.exception.mapClientException import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.notification.RustNotificationService import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService @@ -261,8 +263,8 @@ class RustMatrixClient( return roomFactory.create(roomId) } - override suspend fun getPendingRoom(roomId: RoomId): PendingRoom? { - return roomFactory.createPendingRoom(roomId) + override suspend fun getPendingRoom(roomId: RoomId): RoomPreview? { + return roomFactory.createRoomPreview(roomId) } /** @@ -393,7 +395,7 @@ class RustMatrixClient( null } } - } + }.mapFailure { it.mapClientException() } override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result = withContext(sessionDispatcher) { runCatching { @@ -407,7 +409,7 @@ class RustMatrixClient( Timber.e(e, "Timeout waiting for the room to be available in the room list") null } - } + }.mapFailure { it.mapClientException() } } override suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result = withContext( @@ -421,7 +423,7 @@ class RustMatrixClient( Timber.e(e, "Timeout waiting for the room to be available in the room list") null } - } + }.mapFailure { it.mapClientException() } } override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result = withContext(sessionDispatcher) { @@ -456,7 +458,7 @@ class RustMatrixClient( }.use { roomPreview -> RoomPreviewInfoMapper.map(roomPreview.info()) } - } + }.mapFailure { it.mapClientException() } } override fun syncService(): SyncService = rustSyncService diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt index 0be4ccbc67..5b39595e20 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt @@ -37,7 +37,6 @@ class MatrixRoomInfoMapper { topic = it.topic, avatarUrl = it.avatarUrl, isDirect = it.isDirect, - isPublic = it.isPublic, joinRule = it.joinRule?.map(), isSpace = it.isSpace, isTombstoned = it.isTombstoned, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt index f19d75e752..bf653cbd73 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt @@ -16,8 +16,8 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.PendingRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomPreview import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.awaitLoaded import io.element.android.libraries.matrix.impl.roomlist.fullRoomWithTimeline @@ -28,7 +28,6 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.RoomListException import org.matrix.rustcomponents.sdk.RoomListItem @@ -36,7 +35,6 @@ import timber.log.Timber import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService private const val CACHE_SIZE = 16 -private val PENDING_MEMBERSHIPS = setOf(Membership.INVITED, Membership.KNOCKED) class RustRoomFactory( private val sessionId: SessionId, @@ -125,7 +123,7 @@ class RustRoomFactory( } } - suspend fun createPendingRoom(roomId: RoomId): PendingRoom? = withContext(dispatcher) { + suspend fun createRoomPreview(roomId: RoomId): RoomPreview? = withContext(dispatcher) { if (isDestroyed) { Timber.d("Room factory is destroyed, returning null for $roomId") return@withContext null @@ -135,17 +133,17 @@ class RustRoomFactory( Timber.d("Room not found for $roomId") return@withContext null } - if (roomListItem.membership() !in PENDING_MEMBERSHIPS) { - Timber.d("Room $roomId is not in pending state") + if (roomListItem.membership() !in RustRoomPreview.ALLOWED_MEMBERSHIPS) { + Timber.d("Room $roomId is not in allowed membership") return@withContext null } val innerRoom = try { roomListItem.previewRoom(via = emptyList()) } catch (e: Exception) { - Timber.e(e, "Failed to get pending room for $roomId") + Timber.e(e, "Failed to get room preview for $roomId") return@withContext null } - RustPendingRoom( + RustRoomPreview( sessionId = sessionId, roomId = roomId, inner = innerRoom, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt similarity index 62% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt index f53cf957ac..236e7f1362 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustPendingRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomPreview.kt @@ -9,22 +9,31 @@ package io.element.android.libraries.matrix.impl.room import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.room.PendingRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import org.matrix.rustcomponents.sdk.RoomPreview +import io.element.android.libraries.matrix.api.room.RoomPreview +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.RoomPreview as InnerRoomPreview -class RustPendingRoom( +class RustRoomPreview( override val sessionId: SessionId, override val roomId: RoomId, - private val inner: RoomPreview, + private val inner: InnerRoomPreview, private val roomMembershipObserver: RoomMembershipObserver, -) : PendingRoom { +) : RoomPreview { + companion object { + val ALLOWED_MEMBERSHIPS = setOf(Membership.INVITED, Membership.KNOCKED, Membership.BANNED) + } + override suspend fun leave(): Result = runCatching { inner.leave() }.onSuccess { roomMembershipObserver.notifyUserLeftRoom(roomId) } + override suspend fun forget(): Result = runCatching { + inner.forget() + } + override fun close() { inner.destroy() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt index 5df7b6044b..99fec55a02 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberMapper.kt @@ -25,6 +25,7 @@ object RoomMemberMapper { normalizedPowerLevel = roomMember.normalizedPowerLevel, isIgnored = roomMember.isIgnored, role = mapRole(roomMember.suggestedRoleForPowerLevel), + membershipChangeReason = roomMember.membershipChangeReason ) fun mapRole(role: RoomMemberRole): RoomMember.Role = diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt index c80ce42529..fd21548224 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapper.kt @@ -11,9 +11,8 @@ import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.map -import org.matrix.rustcomponents.sdk.JoinRule -import org.matrix.rustcomponents.sdk.Membership import org.matrix.rustcomponents.sdk.RoomPreviewInfo as RustRoomPreviewInfo object RoomPreviewInfoMapper { @@ -27,10 +26,8 @@ object RoomPreviewInfoMapper { numberOfJoinedMembers = info.numJoinedMembers.toLong(), roomType = info.roomType.map(), isHistoryWorldReadable = info.isHistoryWorldReadable.orFalse(), - isJoined = info.membership == Membership.JOINED, - isInvited = info.membership == Membership.INVITED, - isPublic = info.joinRule == JoinRule.Public, - canKnock = info.joinRule == JoinRule.Knock + membership = info.membership?.map(), + joinRule = info.joinRule.map(), ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt index aaa8ecc9ec..1befd53ae0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapperTest.kt @@ -85,7 +85,6 @@ class MatrixRoomInfoMapperTest { topic = "topic", avatarUrl = AN_AVATAR_URL, isDirect = true, - isPublic = false, isSpace = false, isTombstoned = false, isFavorite = false, @@ -167,7 +166,6 @@ class MatrixRoomInfoMapperTest { topic = null, avatarUrl = null, isDirect = false, - isPublic = true, joinRule = null, isSpace = false, isTombstoned = false, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt index 11a851eee6..4b832799cc 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/preview/RoomPreviewInfoMapperTest.kt @@ -8,14 +8,16 @@ package io.element.android.libraries.matrix.impl.room.preview import com.google.common.truth.Truth.assertThat +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.room.join.JoinRule import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomPreviewInfo import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ID import org.junit.Test -import org.matrix.rustcomponents.sdk.JoinRule import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule class RoomPreviewInfoMapperTest { @Test @@ -23,7 +25,7 @@ class RoomPreviewInfoMapperTest { assertThat( RoomPreviewInfoMapper.map( info = aRustRoomPreviewInfo( - membership = null, + membership = Membership.JOINED, ) ) ).isEqualTo( @@ -36,10 +38,8 @@ class RoomPreviewInfoMapperTest { numberOfJoinedMembers = 1L, roomType = RoomType.Room, isHistoryWorldReadable = true, - isJoined = false, - isInvited = false, - isPublic = true, - canKnock = false, + membership = CurrentUserMembership.JOINED, + joinRule = JoinRule.Public, ) ) } @@ -51,7 +51,7 @@ class RoomPreviewInfoMapperTest { info = aRustRoomPreviewInfo( canonicalAlias = null, membership = Membership.JOINED, - joinRule = JoinRule.Knock, + joinRule = RustJoinRule.Knock, ) ) ).isEqualTo( @@ -64,10 +64,8 @@ class RoomPreviewInfoMapperTest { numberOfJoinedMembers = 1L, roomType = RoomType.Room, isHistoryWorldReadable = true, - isJoined = true, - isInvited = false, - isPublic = false, - canKnock = true, + membership = CurrentUserMembership.JOINED, + joinRule = JoinRule.Knock, ) ) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6cce2e2f98..65946d3f9b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -23,8 +23,8 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom -import io.element.android.libraries.matrix.api.room.PendingRoom import io.element.android.libraries.matrix.api.room.RoomMembershipObserver +import io.element.android.libraries.matrix.api.room.RoomPreview import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService @@ -105,7 +105,7 @@ class FakeMatrixClient( private var createDmResult: Result = Result.success(A_ROOM_ID) private var findDmResult: RoomId? = A_ROOM_ID private val getRoomResults = mutableMapOf() - val getPendingRoomResults = mutableMapOf() + val getRoomPreviewResults = mutableMapOf() private val searchUserResults = mutableMapOf>() private val getProfileResults = mutableMapOf>() private var uploadMediaResult: Result = Result.success(AN_AVATAR_URL) @@ -132,8 +132,8 @@ class FakeMatrixClient( return getRoomResults[roomId] } - override suspend fun getPendingRoom(roomId: RoomId): PendingRoom? { - return getPendingRoomResults[roomId] + override suspend fun getPendingRoom(roomId: RoomId): RoomPreview? { + return getRoomPreviewResults[roomId] } override suspend fun findDM(userId: UserId): RoomId? { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakePendingRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt similarity index 75% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakePendingRoom.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt index 79392cbc44..7fd2925262 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakePendingRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeRoomPreview.kt @@ -9,20 +9,25 @@ package io.element.android.libraries.matrix.test.room import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.room.PendingRoom +import io.element.android.libraries.matrix.api.room.RoomPreview import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask -class FakePendingRoom( +class FakeRoomPreview( override val sessionId: SessionId = A_SESSION_ID, override val roomId: RoomId = A_ROOM_ID, - private val declineInviteResult: () -> Result = { lambdaError() } -) : PendingRoom { + private val declineInviteResult: () -> Result = { lambdaError() }, + private val forgetRoomResult: () -> Result = { lambdaError() }, +) : RoomPreview { override suspend fun leave(): Result = simulateLongTask { declineInviteResult() } + override suspend fun forget(): Result = simulateLongTask { + forgetRoomResult() + } + override fun close() = Unit } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt index ed62d96d98..7711854a81 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt @@ -34,7 +34,6 @@ fun aRoomInfo( topic: String? = A_ROOM_TOPIC, avatarUrl: String? = AN_AVATAR_URL, isDirect: Boolean = false, - isPublic: Boolean = true, joinRule: JoinRule? = JoinRule.Public, isSpace: Boolean = false, isTombstoned: Boolean = false, @@ -67,7 +66,6 @@ fun aRoomInfo( topic = topic, avatarUrl = avatarUrl, isDirect = isDirect, - isPublic = isPublic, joinRule = joinRule, isSpace = isSpace, isTombstoned = isTombstoned, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt index 9a8c35d86d..f4f7f3d835 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -21,6 +21,7 @@ fun aRoomMember( normalizedPowerLevel: Long = 0L, isIgnored: Boolean = false, role: RoomMember.Role = RoomMember.Role.USER, + membershipChangeReason: String? = null, ) = RoomMember( userId = userId, displayName = displayName, @@ -31,4 +32,5 @@ fun aRoomMember( normalizedPowerLevel = normalizedPowerLevel, isIgnored = isIgnored, role = role, + membershipChangeReason = membershipChangeReason, ) 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 new file mode 100644 index 0000000000..e46533e82d --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomPreviewInfoFixture.kt @@ -0,0 +1,43 @@ +/* + * 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.libraries.matrix.test.room + +import io.element.android.libraries.matrix.api.core.RoomAlias +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.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +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_TOPIC + +fun aRoomPreviewInfo( + roomId: RoomId = A_ROOM_ID, + name: String? = A_ROOM_NAME, + topic: String? = A_ROOM_TOPIC, + avatarUrl: String? = AN_AVATAR_URL, + joinRule: JoinRule = JoinRule.Public, + isSpace: Boolean = false, + canonicalAlias: RoomAlias? = null, + currentUserMembership: CurrentUserMembership? = null, + numberOfJoinedMembers: Long = 1, + isHistoryWorldReadable: Boolean = true, +) = RoomPreviewInfo( + roomId = roomId, + name = name, + topic = topic, + avatarUrl = avatarUrl, + joinRule = joinRule, + canonicalAlias = canonicalAlias, + numberOfJoinedMembers = numberOfJoinedMembers, + roomType = if (isSpace) RoomType.Space else RoomType.Room, + isHistoryWorldReadable = isHistoryWorldReadable, + membership = currentUserMembership, +) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index df34bcc734..c85fe824d5 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -47,7 +47,6 @@ fun aRoomSummary( topic: String? = A_ROOM_TOPIC, avatarUrl: String? = null, isDirect: Boolean = false, - isPublic: Boolean = true, joinRule: JoinRule? = JoinRule.Public, isSpace: Boolean = false, isTombstoned: Boolean = false, @@ -82,7 +81,6 @@ fun aRoomSummary( topic = topic, avatarUrl = avatarUrl, isDirect = isDirect, - isPublic = isPublic, joinRule = joinRule, isSpace = isSpace, isTombstoned = isTombstoned, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt index 57c500a1aa..008438305d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/InviteSenderView.kt @@ -55,8 +55,9 @@ internal fun InviteSenderViewPreview() = ElementPreview { id = "@bob:example.com", name = "Bob", url = null, - size = AvatarSize.InviteSender - ) + size = AvatarSize.InviteSender, + ), + membershipChangeReason = null, ) ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt index a9295576e1..778d535fc6 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt @@ -26,6 +26,7 @@ data class InviteSender( val userId: UserId, val displayName: String, val avatarData: AvatarData, + val membershipChangeReason: String?, ) { @Composable fun annotatedString(): AnnotatedString { @@ -52,4 +53,5 @@ fun RoomMember.toInviteSender() = InviteSender( userId = userId, displayName = displayName ?: "", avatarData = getAvatarData(size = AvatarSize.InviteSender), + membershipChangeReason = membershipChangeReason ) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 9430f509d7..d76574c8dc 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -271,6 +271,7 @@ Reason: %1$s." "Verified" "Verify device" "Verify identity" + "Verify user" "Video" "Voice message" "Waiting…" @@ -297,6 +298,7 @@ Reason: %1$s." "%1$s does not have permission to access your location. You can enable access in Settings." "%1$s does not have permission to access your location. Enable access below." "%1$s does not have permission to access your microphone. Enable access to record a voice message." + "This may be due to network or server issues." "This room address already exists. Please try editing the room address field or change the room name" "Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" "Some messages have not been sent" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index d6cfc44134..ac8a51169d 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -36,7 +36,8 @@ { "name" : ":features:roomaliasresolver:impl", "includeRegex" : [ - "screen_room_alias_resolver_.*" + "screen_room_alias_resolver_.*", + "screen.join_room.loading_alert_title" ] }, {