Feature : Report room (#4654)

* feature (report room) : introduce all presentation classes.

* feature (report room) : branch entry point in the room list

* refactor (matrix ui) : move some code from appnav to matrix ui

* feature (report room) : add api on room

* feature (report room) : adjust ui

* feature (report room) : branch api

* feature (decline invite and block) : move things around and introduce presentation classes

* feature (decline invite and block) : continue to move things

* feature (report room) : remove reference to "conversation" for now

* feature (report room) : add report room action to room detail screen

* feature (report room) : enabled button state

* feature (report room) : improve code and reuse

* feature (report room) : add feature flag

* feature (report room) : change feature flag to static bool

* feature (report room) : add tests

* feature (report room) : fix ui with new api on ListItem

* feature (report room) : clean up and add more tests.

* Update screenshots

* feature (report room) : more test and fix issue

* feature (report room) : update strings

* feature (report room) : fix konsist preview

* feature (report room) : disable feature

* Update screenshots

* var -> val

* Improve preview of AcceptDeclineInviteView

* Improve preview consistency

* Add missing test on DismissErrorAndHideContent

* Update screenshots

* Add missing tests

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
ganfra 2025-05-02 12:25:19 +02:00 committed by GitHub
parent e502eb1971
commit 0b83e66733
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
229 changed files with 3995 additions and 1210 deletions

View file

@ -18,7 +18,7 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultJoinRoomEntryPoint @Inject constructor() : JoinRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext, inputs: JoinRoomEntryPoint.Inputs): Node {
return parentNode.createNode<JoinRoomNode>(
return parentNode.createNode<JoinRoomFlowNode>(
buildContext = buildContext,
plugins = listOf(inputs)
)

View file

@ -7,6 +7,8 @@
package io.element.android.features.joinroom.impl
import io.element.android.features.invite.api.InviteData
sealed interface JoinRoomEvents {
data object RetryFetchingContent : JoinRoomEvents
data object DismissErrorAndHideContent : JoinRoomEvents
@ -16,6 +18,6 @@ sealed interface JoinRoomEvents {
data class CancelKnock(val requiresConfirmation: Boolean) : JoinRoomEvents
data class UpdateKnockMessage(val message: String) : JoinRoomEvents
data object ClearActionStates : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents
data class DeclineInvite(val blockUser: Boolean) : JoinRoomEvents
data class AcceptInvite(val inviteData: InviteData) : JoinRoomEvents
data class DeclineInvite(val inviteData: InviteData, val blockUser: Boolean) : JoinRoomEvents
}

View file

@ -0,0 +1,101 @@
/*
* 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.features.joinroom.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteView
import io.element.android.features.invite.api.declineandblock.DeclineInviteAndBlockEntryPoint
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class JoinRoomFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: JoinRoomPresenter.Factory,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val declineAndBlockEntryPoint: DeclineInviteAndBlockEntryPoint
) : BaseFlowNode<JoinRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(
inputs.roomId,
inputs.roomIdOrAlias,
inputs.roomDescription,
inputs.serverNames,
inputs.trigger,
)
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.DeclineInviteAndBlockUser -> declineAndBlockEntryPoint.createNode(this, buildContext, navTarget.inviteData)
NavTarget.Root -> rootNode(buildContext)
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(modifier)
}
private fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier ->
val state = presenter.present()
JoinRoomView(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
onForgetSuccess = ::navigateUp,
onCancelKnockSuccess = {},
onKnockSuccess = {},
onDeclineInviteAndBlockUser = {
backstack.push(
NavTarget.DeclineInviteAndBlockUser(it)
)
},
modifier = modifier
)
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = {},
onDeclineInviteSuccess = {},
modifier = Modifier
)
}
}
}

View file

@ -1,58 +0,0 @@
/*
* 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.features.joinroom.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.invite.api.response.AcceptDeclineInviteView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class JoinRoomNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: JoinRoomPresenter.Factory,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(
inputs.roomId,
inputs.roomIdOrAlias,
inputs.roomDescription,
inputs.serverNames,
inputs.trigger,
)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
JoinRoomView(
state = state,
onBackClick = ::navigateUp,
onJoinSuccess = ::navigateUp,
onForgetSuccess = ::navigateUp,
onCancelKnockSuccess = {},
onKnockSuccess = {},
modifier = modifier
)
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInvite = {},
onDeclineInvite = {},
modifier = Modifier
)
}
}

View file

@ -23,10 +23,11 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.features.invite.api.SeenInvitesStore
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.invite.api.acceptdecline.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.toInviteData
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
@ -170,16 +171,14 @@ class JoinRoomPresenter @AssistedInject constructor(
when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
is JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction, knockMessage)
JoinRoomEvents.AcceptInvite -> {
val inviteData = contentState.toInviteData()
is JoinRoomEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData)
AcceptDeclineInviteEvents.AcceptInvite(event.inviteData)
)
}
is JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData()
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(invite = inviteData, blockUser = event.blockUser)
AcceptDeclineInviteEvents.DeclineInvite(invite = event.inviteData, blockUser = event.blockUser, shouldConfirm = true)
)
}
is JoinRoomEvents.CancelKnock -> coroutineScope.cancelKnockRoom(event.requiresConfirmation, cancelKnockAction)
@ -213,6 +212,7 @@ class JoinRoomPresenter @AssistedInject constructor(
applicationName = buildMeta.applicationName,
knockMessage = knockMessage,
hideInviteAvatars = hideInviteAvatars,
canReportRoom = MatrixConfiguration.CAN_REPORT_ROOM,
eventSink = ::handleEvents
)
}
@ -273,7 +273,12 @@ private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: St
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (membership) {
CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(senderMember?.toInviteSender())
CurrentUserMembership.INVITED -> {
JoinAuthorisationStatus.IsInvited(
inviteData = toInviteData(),
inviteSender = senderMember?.toInviteSender()
)
}
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(senderMember?.toInviteSender(), reason)
CurrentUserMembership.KNOCKED -> JoinAuthorisationStatus.IsKnocked
else -> joinRule.toJoinAuthorisationStatus()
@ -317,7 +322,8 @@ internal fun RoomInfo.toContentState(
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (currentUserMembership) {
CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteSender = membershipSender?.toInviteSender()
inviteData = toInviteData(),
inviteSender = membershipSender?.toInviteSender(),
)
CurrentUserMembership.BANNED -> JoinAuthorisationStatus.IsBanned(
banSender = membershipSender?.toInviteSender(),
@ -340,23 +346,3 @@ private fun JoinRule?.toJoinAuthorisationStatus(): JoinAuthorisationStatus {
else -> JoinAuthorisationStatus.Unknown
}
}
@VisibleForTesting
internal fun ContentState.toInviteData(): InviteData? {
return when (this) {
is ContentState.Loaded -> {
if (joinAuthorisationStatus is JoinAuthorisationStatus.IsInvited && joinAuthorisationStatus.inviteSender != null) {
InviteData(
roomId = roomId,
// 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
}
}

View file

@ -8,7 +8,8 @@
package io.element.android.features.joinroom.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -32,6 +33,7 @@ data class JoinRoomState(
private val applicationName: String,
val knockMessage: String,
val hideInviteAvatars: Boolean,
val canReportRoom: Boolean,
val eventSink: (JoinRoomEvents) -> Unit
) {
val isJoinActionUnauthorized = joinAction is AsyncAction.Failure && joinAction.error is JoinRoomFailures.UnauthorizedJoin
@ -95,7 +97,7 @@ 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 IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus
data class IsBanned(val banSender: InviteSender?, val reason: String?) : JoinAuthorisationStatus
data object IsKnocked : JoinAuthorisationStatus
data object CanKnock : JoinAuthorisationStatus

View file

@ -8,8 +8,9 @@
package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.api.InviteData
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -50,12 +51,20 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
joinAction = AsyncAction.Failure(ClientException.Generic("Something went wrong", null))
),
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
contentState = aLoadedContentState(
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(
inviteData = anInviteData(),
inviteSender = null,
)
)
),
aJoinRoomState(
contentState = aLoadedContentState(
numberOfMembers = 123,
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(
inviteData = anInviteData(),
inviteSender = anInviteSender(),
),
)
),
aJoinRoomState(
@ -149,7 +158,7 @@ fun aLoadedContentState(
isDm: Boolean = false,
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown,
) = ContentState.Loaded(
roomId = roomId,
name = name,
@ -172,6 +181,7 @@ fun aJoinRoomState(
cancelKnockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
knockMessage: String = "",
hideInviteAvatars: Boolean = false,
canReportRoom: Boolean = true,
eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState(
roomIdOrAlias = roomIdOrAlias,
@ -184,6 +194,7 @@ fun aJoinRoomState(
applicationName = "AppName",
knockMessage = knockMessage,
hideInviteAvatars = hideInviteAvatars,
canReportRoom = canReportRoom,
eventSink = eventSink
)
@ -199,5 +210,15 @@ internal fun anInviteSender(
membershipChangeReason = membershipChangeReason,
)
internal fun anInviteData(
roomId: RoomId = A_ROOM_ID,
roomName: String = "Room name",
isDm: Boolean = false,
) = InviteData(
roomId = roomId,
roomName = roomName,
isDm = isDm,
)
private val A_ROOM_ID = RoomId("!exa:matrix.org")
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")

View file

@ -36,6 +36,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
@ -78,6 +79,7 @@ fun JoinRoomView(
onKnockSuccess: () -> Unit,
onForgetSuccess: () -> Unit,
onCancelKnockSuccess: () -> Unit,
onDeclineInviteAndBlockUser: (InviteData) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
@ -104,11 +106,15 @@ fun JoinRoomView(
footer = {
JoinRoomFooter(
joinAuthorisationStatus = state.joinAuthorisationStatus,
onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite)
onAcceptInvite = { inviteData ->
state.eventSink(JoinRoomEvents.AcceptInvite(inviteData))
},
onDeclineInvite = { blockUser ->
state.eventSink(JoinRoomEvents.DeclineInvite(blockUser))
onDeclineInvite = { inviteData, blockUser ->
if (state.canReportRoom && blockUser) {
onDeclineInviteAndBlockUser(inviteData)
} else {
state.eventSink(JoinRoomEvents.DeclineInvite(inviteData, blockUser = blockUser))
}
},
onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom)
@ -184,8 +190,8 @@ fun JoinRoomView(
@Composable
private fun JoinRoomFooter(
joinAuthorisationStatus: JoinAuthorisationStatus,
onAcceptInvite: () -> Unit,
onDeclineInvite: (Boolean) -> Unit,
onAcceptInvite: (InviteData) -> Unit,
onDeclineInvite: (InviteData, Boolean) -> Unit,
onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit,
onCancelKnock: () -> Unit,
@ -204,13 +210,13 @@ private fun JoinRoomFooter(
ButtonRowMolecule(horizontalArrangement = Arrangement.spacedBy(20.dp)) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = { onDeclineInvite(false) },
onClick = { onDeclineInvite(joinAuthorisationStatus.inviteData, false) },
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite,
onClick = { onAcceptInvite(joinAuthorisationStatus.inviteData) },
modifier = Modifier.weight(1f),
size = ButtonSize.LargeLowPadding,
)
@ -218,7 +224,7 @@ private fun JoinRoomFooter(
Spacer(modifier = Modifier.height(24.dp))
TextButton(
text = stringResource(R.string.screen_join_room_decline_and_block_button_title),
onClick = { onDeclineInvite(true) },
onClick = { onDeclineInvite(joinAuthorisationStatus.inviteData, true) },
modifier = Modifier.fillMaxWidth(),
destructive = true
)
@ -585,5 +591,6 @@ internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class)
onKnockSuccess = { },
onForgetSuccess = { },
onCancelKnockSuccess = { },
onDeclineInviteAndBlockUser = { },
)
}

View file

@ -12,7 +12,7 @@ import dagger.Module
import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter