change(invites) : add logic to decline invite and block a user

This commit is contained in:
ganfra 2025-02-27 21:09:47 +01:00
parent bd46336568
commit 148fe9db43
13 changed files with 145 additions and 63 deletions

View file

@ -8,6 +8,6 @@
package io.element.android.features.invite.api.response package io.element.android.features.invite.api.response
interface AcceptDeclineInviteEvents { interface AcceptDeclineInviteEvents {
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class AcceptInvite(val invite: InviteData?) : AcceptDeclineInviteEvents
data class DeclineInvite(val invite: InviteData) : AcceptDeclineInviteEvents data class DeclineInvite(val invite: InviteData?, val blockUser: Boolean = false) : AcceptDeclineInviteEvents
} }

View file

@ -10,6 +10,8 @@ package io.element.android.features.invite.api.response
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.IntentionalMention
open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> { open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDeclineInviteState> {
override val values: Sequence<AcceptDeclineInviteState> override val values: Sequence<AcceptDeclineInviteState>
@ -17,12 +19,20 @@ open class AcceptDeclineInviteStateProvider : PreviewParameterProvider<AcceptDec
anAcceptDeclineInviteState(), anAcceptDeclineInviteState(),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = true, roomName = "Alice") InviteData(roomId = RoomId("!room:matrix.org"), isDm = true, roomName = "Alice", senderId = UserId("@alice:matrix.org")),
blockUser = false,
), ),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite( declineAction = ConfirmingDeclineInvite(
InviteData(RoomId("!room:matrix.org"), isDm = false, roomName = "Some room") InviteData(roomId = RoomId("!room:matrix.org"), isDm = false, roomName = "Some room", senderId = UserId("@alice:matrix.org")),
blockUser = false,
),
),
anAcceptDeclineInviteState(
declineAction = ConfirmingDeclineInvite(
InviteData(roomId = RoomId("!room:matrix.org"), isDm = true, roomName = "Alice", senderId = UserId("@alice:matrix.org")),
blockUser = true,
), ),
), ),
anAcceptDeclineInviteState( anAcceptDeclineInviteState(

View file

@ -11,4 +11,5 @@ import io.element.android.libraries.architecture.AsyncAction
data class ConfirmingDeclineInvite( data class ConfirmingDeclineInvite(
val inviteData: InviteData, val inviteData: InviteData,
val blockUser: Boolean,
) : AsyncAction.Confirming ) : AsyncAction.Confirming

View file

@ -8,8 +8,10 @@
package io.element.android.features.invite.api.response package io.element.android.features.invite.api.response
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
data class InviteData( data class InviteData(
val senderId: UserId,
val roomId: RoomId, val roomId: RoomId,
val roomName: String, val roomName: String,
val isDm: Boolean, val isDm: Boolean,

View file

@ -16,6 +16,7 @@ import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents 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.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runCatchingUpdatingState
@ -43,15 +44,34 @@ class AcceptDeclineInvitePresenter @Inject constructor(
fun handleEvents(event: AcceptDeclineInviteEvents) { fun handleEvents(event: AcceptDeclineInviteEvents) {
when (event) { when (event) {
is AcceptDeclineInviteEvents.AcceptInvite -> { is AcceptDeclineInviteEvents.AcceptInvite -> {
localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) val inviteData = event.invite
if (inviteData == null) {
acceptedAction.value = AsyncAction.Failure(InvalidDataException())
} else {
localCoroutineScope.acceptInvite(inviteData.roomId, acceptedAction)
}
} }
is AcceptDeclineInviteEvents.DeclineInvite -> { is AcceptDeclineInviteEvents.DeclineInvite -> {
declinedAction.value = ConfirmingDeclineInvite(event.invite) val inviteData = event.invite
if (inviteData == null) {
declinedAction.value = AsyncAction.Failure(InvalidDataException())
} else {
declinedAction.value = ConfirmingDeclineInvite(inviteData, event.blockUser)
}
} }
is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> { is InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite -> {
localCoroutineScope.declineInvite(event.roomId, declinedAction) when (val declinedActionValue = declinedAction.value) {
is ConfirmingDeclineInvite -> {
localCoroutineScope.declineInvite(
inviteData = declinedActionValue.inviteData,
declinedAction = declinedAction,
blockUser = declinedActionValue.blockUser,
)
}
else -> Unit
}
} }
is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> { is InternalAcceptDeclineInviteEvents.CancelDeclineInvite -> {
@ -92,13 +112,21 @@ class AcceptDeclineInvitePresenter @Inject constructor(
} }
} }
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<AsyncAction<RoomId>>) = launch { private fun CoroutineScope.declineInvite(
inviteData: InviteData,
blockUser: Boolean,
declinedAction: MutableState<AsyncAction<RoomId>>,
) = launch {
suspend { suspend {
client.getPendingRoom(roomId)?.use { client.getPendingRoom(inviteData.roomId)?.use {
it.leave().getOrThrow() it.leave().getOrThrow()
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
} }
roomId val senderId = inviteData.senderId
if (blockUser && senderId != null) {
client.ignoreUser(senderId).getOrThrow()
}
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
inviteData.roomId
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }
} }

View file

@ -50,8 +50,9 @@ fun AcceptDeclineInviteView(
if (confirming is ConfirmingDeclineInvite) { if (confirming is ConfirmingDeclineInvite) {
DeclineConfirmationDialog( DeclineConfirmationDialog(
invite = confirming.inviteData, invite = confirming.inviteData,
blockUser = confirming.blockUser,
onConfirmClick = { onConfirmClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(confirming.inviteData.roomId)) state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite)
}, },
onDismissClick = { onDismissClick = {
state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite)
@ -66,29 +67,35 @@ fun AcceptDeclineInviteView(
@Composable @Composable
private fun DeclineConfirmationDialog( private fun DeclineConfirmationDialog(
invite: InviteData, invite: InviteData,
blockUser: Boolean,
onConfirmClick: () -> Unit, onConfirmClick: () -> Unit,
onDismissClick: () -> Unit, onDismissClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contentResource = if (invite.isDm) { val senderId = invite.senderId.value
R.string.screen_invites_decline_direct_chat_message val content = when {
} else { blockUser -> stringResource(R.string.screen_join_room_decline_and_block_alert_message, senderId)
R.string.screen_invites_decline_chat_message invite.isDm -> stringResource(R.string.screen_invites_decline_direct_chat_message, invite.roomName)
else -> stringResource(R.string.screen_invites_decline_chat_message, invite.roomName)
} }
val title = when {
val titleResource = if (invite.isDm) { blockUser -> stringResource(R.string.screen_join_room_decline_and_block_alert_title)
R.string.screen_invites_decline_direct_chat_title invite.isDm -> stringResource(R.string.screen_invites_decline_direct_chat_title)
} else { else -> stringResource(R.string.screen_invites_decline_chat_title)
R.string.screen_invites_decline_chat_title }
val submitText = if (blockUser) {
stringResource(R.string.screen_join_room_decline_and_block_alert_confirmation)
} else {
stringResource(CommonStrings.action_decline)
} }
ConfirmationDialog( ConfirmationDialog(
modifier = modifier, modifier = modifier,
content = stringResource(contentResource, invite.roomName), content = content,
title = stringResource(titleResource), title = title,
submitText = stringResource(CommonStrings.action_decline), submitText = submitText,
cancelText = stringResource(CommonStrings.action_cancel), cancelText = stringResource(CommonStrings.action_cancel),
onSubmitClick = onConfirmClick, onSubmitClick = onConfirmClick,
destructiveSubmit = blockUser,
onDismiss = onDismissClick, onDismiss = onDismissClick,
) )
} }

View file

@ -8,10 +8,9 @@
package io.element.android.features.invite.impl.response package io.element.android.features.invite.impl.response
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents { sealed interface InternalAcceptDeclineInviteEvents : AcceptDeclineInviteEvents {
data class ConfirmDeclineInvite(val roomId: RoomId) : InternalAcceptDeclineInviteEvents data object ConfirmDeclineInvite : InternalAcceptDeclineInviteEvents
data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents data object CancelDeclineInvite : InternalAcceptDeclineInviteEvents
data object DismissAcceptError : InternalAcceptDeclineInviteEvents data object DismissAcceptError : InternalAcceptDeclineInviteEvents
data object DismissDeclineError : InternalAcceptDeclineInviteEvents data object DismissDeclineError : InternalAcceptDeclineInviteEvents

View file

@ -0,0 +1,10 @@
/*
* 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.invite.impl.response
class InvalidDataException : Exception()

View file

@ -17,10 +17,12 @@ 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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomPreview import io.element.android.libraries.matrix.test.room.FakeRoomPreview
import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom
@ -93,7 +95,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId) InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite()
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
@ -141,7 +143,7 @@ class AcceptDeclineInvitePresenterTest {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData)) assertThat(state.declineAction).isEqualTo(ConfirmingDeclineInvite(inviteData))
state.eventSink( state.eventSink(
InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite(inviteData.roomId) InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite()
) )
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
@ -237,12 +239,14 @@ class AcceptDeclineInvitePresenterTest {
private fun anInviteData( private fun anInviteData(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = A_ROOM_NAME, name: String = A_ROOM_NAME,
isDm: Boolean = false isDm: Boolean = false,
senderId: UserId = A_USER_ID,
): InviteData { ): InviteData {
return InviteData( return InviteData(
roomId = roomId, roomId = roomId,
roomName = name, roomName = name,
isDm = isDm isDm = isDm,
senderId = senderId,
) )
} }

View file

@ -17,5 +17,5 @@ sealed interface JoinRoomEvents {
data class UpdateKnockMessage(val message: String) : JoinRoomEvents data class UpdateKnockMessage(val message: String) : JoinRoomEvents
data object ClearActionStates : JoinRoomEvents data object ClearActionStates : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents data class DeclineInvite(val blockUser: Boolean) : JoinRoomEvents
} }

View file

@ -152,15 +152,15 @@ class JoinRoomPresenter @AssistedInject constructor(
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage) is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage)
JoinRoomEvents.AcceptInvite -> { JoinRoomEvents.AcceptInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData()
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvents.AcceptInvite(inviteData)
) )
} }
JoinRoomEvents.DeclineInvite -> { is JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData()
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(invite = inviteData, blockUser = event.blockUser)
) )
} }
is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction) is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction)
@ -314,12 +314,19 @@ private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
@VisibleForTesting @VisibleForTesting
internal fun ContentState.toInviteData(): InviteData? { internal fun ContentState.toInviteData(): InviteData? {
return when (this) { return when (this) {
is ContentState.Loaded -> InviteData( is ContentState.Loaded -> {
roomId = roomId, if (joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited && joinAuthorisationStatus.inviteSender != null) {
// Note: name should not be null at this point, but use Id just in case... InviteData(
roomName = name ?: roomId.value, roomId = roomId,
isDm = isDm // Note: name should not be null at this point, but use Id just in case...
) roomName = name ?: roomId.value,
senderId = joinAuthorisationStatus.inviteSender.userId,
isDm = isDm
)
} else {
null
}
}
else -> null else -> null
} }
} }

View file

@ -63,6 +63,7 @@ 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.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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.core.RoomIdOrAlias
@ -105,8 +106,8 @@ fun JoinRoomView(
onAcceptInvite = { onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite) state.eventSink(JoinRoomEvents.AcceptInvite)
}, },
onDeclineInvite = { onDeclineInvite = { blockUser ->
state.eventSink(JoinRoomEvents.DeclineInvite) state.eventSink(JoinRoomEvents.DeclineInvite(blockUser))
}, },
onJoinRoom = { onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvents.JoinRoom)
@ -183,7 +184,7 @@ fun JoinRoomView(
private fun JoinRoomFooter( private fun JoinRoomFooter(
joinAuthorisationStatus: JoinAuthorisationStatus, joinAuthorisationStatus: JoinAuthorisationStatus,
onAcceptInvite: () -> Unit, onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit, onDeclineInvite: (Boolean) -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit, onKnockRoom: () -> Unit,
onCancelKnock: () -> Unit, onCancelKnock: () -> Unit,
@ -198,19 +199,29 @@ private fun JoinRoomFooter(
) { ) {
when (joinAuthorisationStatus) { when (joinAuthorisationStatus) {
is JoinAuthorisationStatus.IsInvited -> { is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) { Column {
OutlinedButton( ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
text = stringResource(CommonStrings.action_decline), OutlinedButton(
onClick = onDeclineInvite, text = stringResource(CommonStrings.action_decline),
modifier = Modifier.weight(1f), onClick = { onDeclineInvite(false) },
size = ButtonSize.LargeLowPadding, modifier = Modifier.weight(1f),
) size = ButtonSize.LargeLowPadding,
Button( )
text = stringResource(CommonStrings.action_accept), Button(
onClick = onAcceptInvite, text = stringResource(CommonStrings.action_accept),
modifier = Modifier.weight(1f), onClick = onAcceptInvite,
size = ButtonSize.LargeLowPadding, modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
}
Spacer(modifier = Modifier.height(24.dp))
TextButton(
text = stringResource(R.string.screen_join_room_decline_and_block_button_title),
onClick = { onDeclineInvite(true) },
modifier = Modifier.fillMaxWidth(),
destructive = true
) )
} }
} }
JoinAuthorisationStatus.CanJoin -> { JoinAuthorisationStatus.CanJoin -> {

View file

@ -329,9 +329,12 @@ class RoomListPresenter @Inject constructor(
} }
@VisibleForTesting @VisibleForTesting
internal fun RoomListRoomSummary.toInviteData() = InviteData( internal fun RoomListRoomSummary.toInviteData(): InviteData? {
roomId = roomId, if (inviteSender == null) return null
// Note: `name` should not be null at this point, but just in case, fallback to the roomId return InviteData(
roomName = name ?: roomId.value, roomId = roomId,
isDm = isDm, roomName = name ?: roomId.value,
) isDm = isDm,
senderId = inviteSender.userId,
)
}