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.
This commit is contained in:
ganfra 2025-01-10 09:52:02 +01:00 committed by Jorge Martin Espinosa
parent 819503b162
commit a73bcb71d5
50 changed files with 886 additions and 357 deletions

View file

@ -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<Unit>(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,

View file

@ -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

View file

@ -43,6 +43,7 @@ class JoinRoomNode @AssistedInject constructor(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
onForgetSuccess = ::navigateUp,
onCancelKnockSuccess = {},
onKnockSuccess = {},
modifier = modifier

View file

@ -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<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
) : Presenter<JoinRoomState> {
@ -79,13 +85,17 @@ class JoinRoomPresenter @AssistedInject constructor(
val joinAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val cancelKnockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val forgetRoomAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
var knockMessage by rememberSaveable { mutableStateOf("") }
var isDismissingContent by remember { mutableStateOf(false) }
val contentState by produceState<ContentState>(
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<AsyncAction<Unit>>) = 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) {

View file

@ -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<Unit>,
val knockAction: AsyncAction<Unit>,
val forgetAction: AsyncAction<Unit>,
val cancelKnockAction: AsyncAction<Unit>,
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()
}

View file

@ -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<JoinRoomState> {
override val values: Sequence<JoinRoomState>
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<JoinRoomState> {
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<JoinRoomState> {
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<JoinRoomState> {
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<Unit> = AsyncAction.Uninitialized,
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
forgetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cancelKnockAction: AsyncAction<Unit> = 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")

View file

@ -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 = { },
)
}

View file

@ -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<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultForgetRoom @Inject constructor(private val client: MatrixClient) : ForgetRoom {
override suspend fun invoke(roomId: RoomId): Result<Unit> {
return client
.getPendingRoom(roomId)
?.forget()
?: Result.failure(IllegalStateException("No pending room found"))
}
}

View file

@ -32,6 +32,7 @@ object JoinRoomModule {
joinRoom: JoinRoom,
knockRoom: KnockRoom,
cancelKnockRoom: CancelKnockRoom,
forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
): JoinRoomPresenter.Factory {
@ -52,6 +53,7 @@ object JoinRoomModule {
matrixClient = client,
joinRoom = joinRoom,
knockRoom = knockRoom,
forgetRoom = forgetRoom,
cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,

View file

@ -17,6 +17,8 @@
<string name="screen_join_room_knock_message_description">"Message (optional)"</string>
<string name="screen_join_room_knock_sent_description">"You will receive an invite to join the room if your request is accepted."</string>
<string name="screen_join_room_knock_sent_title">"Request to join sent"</string>
<string name="screen_join_room_loading_alert_message">"We could not display the room preview. This may be due to network or server issues."</string>
<string name="screen_join_room_loading_alert_title">"We couldnt display this room preview"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>

View file

@ -0,0 +1,20 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.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<Unit> = { Result.success(Unit) }
) : ForgetRoom {
override suspend fun invoke(roomId: RoomId) = simulateLongTask {
lambda(roomId)
}
}

View file

@ -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<Unit>(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<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter {
@ -534,6 +697,7 @@ class JoinRoomPresenterTest {
joinRoom = FakeJoinRoom(joinRoomLambda),
knockRoom = knockRoom,
cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
)

View file

@ -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<JoinRoomEvents>(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<JoinRoomEvents>(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<JoinRoomEvents>()
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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomView(
@ -199,6 +228,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinR
onJoinSuccess: () -> Unit = EnsureNeverCalled(),
onKnockSuccess: () -> Unit = EnsureNeverCalled(),
onCancelKnockSuccess: () -> Unit = EnsureNeverCalled(),
onForgetSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
JoinRoomView(
@ -206,7 +236,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinR
onBackClick = onBackClick,
onJoinSuccess = onJoinSuccess,
onKnockSuccess = onKnockSuccess,
onCancelKnockSuccess = onCancelKnockSuccess
onForgetSuccess = onForgetSuccess,
onCancelKnockSuccess = onCancelKnockSuccess,
)
}
}

View file

@ -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(

View file

@ -9,4 +9,5 @@ package io.element.android.features.roomaliasresolver.impl
sealed interface RoomAliasResolverEvents {
data object Retry : RoomAliasResolverEvents
data object DismissError : RoomAliasResolverEvents
}

View file

@ -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)
}
}

View file

@ -18,3 +18,7 @@ data class RoomAliasResolverState(
val resolveState: AsyncData<ResolvedRoomAlias>,
val eventSink: (RoomAliasResolverEvents) -> Unit
)
sealed class RoomAliasResolverFailures : Exception() {
data object UnknownAlias : RoomAliasResolverFailures()
}

View file

@ -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<RoomAliasResolverState> {
@ -17,10 +18,10 @@ open class RoomAliasResolverStateProvider : PreviewParameterProvider<RoomAliasRe
get() = sequenceOf(
aRoomAliasResolverState(),
aRoomAliasResolverState(
resolveState = AsyncData.Loading(),
resolveState = AsyncData.Failure(ClientException.Generic("Something went wrong")),
),
aRoomAliasResolverState(
resolveState = AsyncData.Failure(Exception("Error")),
resolveState = AsyncData.Failure(RoomAliasResolverFailures.UnknownAlias),
),
)
}

View file

@ -7,12 +7,11 @@
package io.element.android.features.roomaliasresolver.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -21,25 +20,22 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
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.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.ui.strings.CommonStrings
@ -50,66 +46,72 @@ fun RoomAliasResolverView(
onSuccess: (ResolvedRoomAlias) -> 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<ResolvedRoomAlias>,
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 = {
}
)
}

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_loading_alert_title">"We couldnt display this room preview"</string>
<string name="screen_room_alias_resolver_resolve_alias_failure">"Failed to resolve room alias."</string>
</resources>

View file

@ -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(

View file

@ -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(

View file

@ -73,7 +73,6 @@ fun aMatrixRoom(
topic = topic,
avatarUrl = avatarUrl,
isDirect = isDirect,
isPublic = isPublic,
joinRule = joinRule,
)
)

View file

@ -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),

View file

@ -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 {

View file

@ -123,6 +123,7 @@ internal fun anInviteSender(
userId = userId,
displayName = displayName,
avatarData = avatarData,
membershipChangeReason = null,
)
internal fun aRoomListRoomSummary(

View file

@ -13,9 +13,11 @@
<string name="screen_session_verification_cancelled_subtitle">"Something doesnt seem right. Either the request timed out or the request was denied."</string>
<string name="screen_session_verification_compare_emojis_subtitle">"Confirm that the emojis below match those shown on your other session."</string>
<string name="screen_session_verification_compare_emojis_title">"Compare emojis"</string>
<string name="screen_session_verification_compare_emojis_user_subtitle">"Confirm that the emojis below match those shown on the other users device."</string>
<string name="screen_session_verification_compare_numbers_subtitle">"Confirm that the numbers below match those shown on your other session."</string>
<string name="screen_session_verification_compare_numbers_title">"Compare numbers"</string>
<string name="screen_session_verification_complete_subtitle">"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted."</string>
<string name="screen_session_verification_complete_user_subtitle">"Now you can trust the identity of this user when sending or receiving messages."</string>
<string name="screen_session_verification_enter_recovery_key">"Enter recovery key"</string>
<string name="screen_session_verification_failed_subtitle">"Either the request timed out, the request was denied, or there was a verification mismatch."</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Prove its you in order to access your encrypted message history."</string>
@ -37,8 +39,14 @@
<string name="screen_session_verification_they_match">"They match"</string>
<string name="screen_session_verification_use_another_device_subtitle">"Make sure you have the app open in the other device before starting verification from here."</string>
<string name="screen_session_verification_use_another_device_title">"Open the app on another verified device"</string>
<string name="screen_session_verification_user_initiator_subtitle">"For extra security, verify this user by comparing a set of emojis on your devices. Do this by using a trusted way to communicate."</string>
<string name="screen_session_verification_user_initiator_title">"Verify this user?"</string>
<string name="screen_session_verification_user_responder_subtitle">"For extra security, another user wants to verify your identity. Youll be shown a set of emojis to compare."</string>
<string name="screen_session_verification_waiting_another_device_subtitle">"You should see a popup on the other device. Start the verification from there now."</string>
<string name="screen_session_verification_waiting_another_device_title">"Start verification on the other device"</string>
<string name="screen_session_verification_waiting_other_device_title">"Waiting for the other device"</string>
<string name="screen_session_verification_waiting_other_user_title">"Waiting for the other user"</string>
<string name="screen_session_verification_waiting_subtitle">"Once accepted youll be able to continue with the verification."</string>
<string name="screen_session_verification_waiting_to_accept_subtitle">"Accept the request to start the verification process in your other session to continue."</string>
<string name="screen_session_verification_waiting_to_accept_title">"Waiting to accept request"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>

View file

@ -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<ImmutableList<UserId>>
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<Unit>
suspend fun unignoreUser(userId: UserId): Result<Unit>

View file

@ -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,

View file

@ -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].

View file

@ -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<Unit>
/**
* Forget the room if we had access to it, and it was left or banned.
*/
suspend fun forget(): Result<Unit>
}

View file

@ -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,
)

View file

@ -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<String>): Result<RoomSummary?> = 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<String>): Result<RoomSummary?> = 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<Unit> = withContext(sessionDispatcher) {
@ -456,7 +458,7 @@ class RustMatrixClient(
}.use { roomPreview ->
RoomPreviewInfoMapper.map(roomPreview.info())
}
}
}.mapFailure { it.mapClientException() }
}
override fun syncService(): SyncService = rustSyncService

View file

@ -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,

View file

@ -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,

View file

@ -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<Unit> = runCatching {
inner.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId)
}
override suspend fun forget(): Result<Unit> = runCatching {
inner.forget()
}
override fun close() {
inner.destroy()
}

View file

@ -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 =

View file

@ -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(),
)
}
}

View file

@ -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,

View file

@ -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,
)
)
}

View file

@ -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<RoomId> = Result.success(A_ROOM_ID)
private var findDmResult: RoomId? = A_ROOM_ID
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
val getPendingRoomResults = mutableMapOf<RoomId, PendingRoom>()
val getRoomPreviewResults = mutableMapOf<RoomId, RoomPreview>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
private val getProfileResults = mutableMapOf<UserId, Result<MatrixUser>>()
private var uploadMediaResult: Result<String> = 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? {

View file

@ -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<Unit> = { lambdaError() }
) : PendingRoom {
private val declineInviteResult: () -> Result<Unit> = { lambdaError() },
private val forgetRoomResult: () -> Result<Unit> = { lambdaError() },
) : RoomPreview {
override suspend fun leave(): Result<Unit> = simulateLongTask {
declineInviteResult()
}
override suspend fun forget(): Result<Unit> = simulateLongTask {
forgetRoomResult()
}
override fun close() = Unit
}

View file

@ -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,

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,

View file

@ -55,8 +55,9 @@ internal fun InviteSenderViewPreview() = ElementPreview {
id = "@bob:example.com",
name = "Bob",
url = null,
size = AvatarSize.InviteSender
)
size = AvatarSize.InviteSender,
),
membershipChangeReason = null,
)
)
}

View file

@ -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
)

View file

@ -271,6 +271,7 @@ Reason: %1$s."</string>
<string name="common_verified">"Verified"</string>
<string name="common_verify_device">"Verify device"</string>
<string name="common_verify_identity">"Verify identity"</string>
<string name="common_verify_user">"Verify user"</string>
<string name="common_video">"Video"</string>
<string name="common_voice_message">"Voice message"</string>
<string name="common_waiting">"Waiting…"</string>
@ -297,6 +298,7 @@ Reason: %1$s."</string>
<string name="error_missing_location_auth_android">"%1$s does not have permission to access your location. You can enable access in Settings."</string>
<string name="error_missing_location_rationale_android">"%1$s does not have permission to access your location. Enable access below."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s does not have permission to access your microphone. Enable access to record a voice message."</string>
<string name="error_network_or_server_issue">"This may be due to network or server issues."</string>
<string name="error_room_address_already_exists">"This room address already exists. Please try editing the room address field or change the room name"</string>
<string name="error_room_address_invalid_symbols">"Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>

View file

@ -36,7 +36,8 @@
{
"name" : ":features:roomaliasresolver:impl",
"includeRegex" : [
"screen_room_alias_resolver_.*"
"screen_room_alias_resolver_.*",
"screen.join_room.loading_alert_title"
]
},
{