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 db6bc7c04f
commit c2568f84d2
229 changed files with 3995 additions and 1210 deletions

View file

@ -28,7 +28,7 @@ class DefaultRoomListEntryPoint @Inject constructor() : RoomListEntryPoint {
}
override fun build(): Node {
return parentNode.createNode<RoomListNode>(buildContext, plugins)
return parentNode.createNode<RoomListFlowNode>(buildContext, plugins)
}
}
}

View file

@ -34,14 +34,17 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean,
eventSink: (RoomListEvents.ContextMenuEvents) -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) },
) {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
canReportRoom = canReportRoom,
onRoomMarkReadClick = {
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId))
@ -65,6 +68,10 @@ fun RoomListContextMenu(
eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.ClearCacheOfRoom(contextMenu.roomId))
},
onReportRoomClick = {
eventSink(RoomListEvents.HideContextMenu)
onReportRoomClick(contextMenu.roomId)
},
)
}
}
@ -72,12 +79,14 @@ fun RoomListContextMenu(
@Composable
private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean,
onRoomSettingsClick: () -> Unit,
onLeaveRoomClick: () -> Unit,
onFavoriteChange: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClick: () -> Unit,
onRoomMarkUnreadClick: () -> Unit,
onClearCacheRoomClick: () -> Unit,
onReportRoomClick: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth()
@ -157,16 +166,24 @@ private fun RoomListModalBottomSheetContent(
),
style = ListItemStyle.Primary,
)
if (canReportRoom) {
ListItem(
headlineContent = {
Text(text = stringResource(CommonStrings.action_report_room))
},
modifier = Modifier.clickable { onReportRoomClick() },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.ChatProblem(),
contentDescription = stringResource(CommonStrings.action_report_room),
)
),
style = ListItemStyle.Destructive,
)
}
ListItem(
headlineContent = {
val leaveText = stringResource(
id = if (contextMenu.isDm) {
CommonStrings.action_leave_conversation
} else {
CommonStrings.action_leave_room
}
)
Text(text = leaveText)
Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
leadingContent = ListItemContent.Icon(
@ -201,11 +218,13 @@ internal fun RoomListModalBottomSheetContentPreview(
) = ElementPreview {
RoomListModalBottomSheetContent(
contextMenu = contextMenu,
canReportRoom = true,
onRoomMarkReadClick = {},
onRoomMarkUnreadClick = {},
onRoomSettingsClick = {},
onLeaveRoomClick = {},
onFavoriteChange = {},
onClearCacheRoomClick = {},
onReportRoomClick = {},
)
}

View file

@ -0,0 +1,125 @@
/*
* Copyright 2023, 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.roomlist.impl
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
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.ModalBottomSheet
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.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoomListDeclineInviteMenu(
menu: RoomListState.DeclineInviteMenu.Shown,
canReportRoom: Boolean,
onDeclineAndBlockClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit,
) {
ModalBottomSheet(
onDismissRequest = { eventSink(RoomListEvents.HideDeclineInviteMenu) },
) {
RoomListDeclineInviteMenuContent(
roomName = menu.roomSummary.name ?: menu.roomSummary.roomId.value,
onDeclineClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, false))
},
onDeclineAndBlockClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
if (canReportRoom) {
onDeclineAndBlockClick(menu.roomSummary)
} else {
eventSink(RoomListEvents.DeclineInvite(menu.roomSummary, true))
}
},
onCancelClick = {
eventSink(RoomListEvents.HideDeclineInviteMenu)
}
)
}
}
@Composable
private fun RoomListDeclineInviteMenuContent(
roomName: String,
onDeclineClick: () -> Unit,
onDeclineAndBlockClick: () -> Unit,
onCancelClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(all = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.screen_invites_decline_chat_title),
style = ElementTheme.typography.fontHeadingSmMedium,
color = ElementTheme.colors.textPrimary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_invites_decline_chat_message, roomName),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(Modifier.height(22.dp))
Button(
text = stringResource(CommonStrings.action_decline),
modifier = Modifier.fillMaxWidth(),
onClick = onDeclineClick,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(
text = stringResource(CommonStrings.action_decline_and_block),
modifier = Modifier.fillMaxWidth(),
destructive = true,
onClick = onDeclineAndBlockClick
)
Spacer(Modifier.height(16.dp))
TextButton(
text = stringResource(CommonStrings.action_cancel),
modifier = Modifier.fillMaxWidth(),
onClick = onCancelClick
)
}
}
// TODO This component should be seen in [RoomListView] @Preview but it doesn't show up.
// see: https://issuetracker.google.com/issues/283843380
// Remove this preview when the issue is fixed.
@PreviewsDayNight
@Composable
internal fun RoomListDeclineInviteMenuContentPreview() = ElementPreview {
RoomListDeclineInviteMenuContent(
roomName = "Room name",
onCancelClick = {},
onDeclineClick = {},
onDeclineAndBlockClick = {},
)
}

View file

@ -15,9 +15,12 @@ sealed interface RoomListEvents {
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissBanner : RoomListEvents
data object ToggleSearchResults : RoomListEvents
data class AcceptInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class DeclineInvite(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
data class AcceptInvite(val roomSummary: RoomListRoomSummary) : RoomListEvents
data class DeclineInvite(val roomSummary: RoomListRoomSummary, val blockUser: Boolean) : RoomListEvents
data class ShowDeclineInviteMenu(val roomSummary: RoomListRoomSummary) : RoomListEvents
data object HideDeclineInviteMenu : RoomListEvents
sealed interface ContextMenuEvents : RoomListEvents
data object HideContextMenu : ContextMenuEvents

View file

@ -0,0 +1,166 @@
/*
* Copyright 2023, 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.roomlist.impl
import android.app.Activity
import android.os.Parcelable
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
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.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
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.logout.api.direct.DirectLogoutView
import io.element.android.features.reportroom.api.ReportRoomEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class RoomListFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val directLogoutView: DirectLogoutView,
private val reportRoomEntryPoint: ReportRoomEntryPoint,
private val declineInviteAndBlockUserEntryPoint: DeclineInviteAndBlockEntryPoint,
) : BaseFlowNode<RoomListFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
}
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class ReportRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class DeclineInviteAndBlockUser(val inviteData: InviteData) : NavTarget
}
private fun onRoomClick(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomClick(roomId) }
}
private fun onOpenSettings() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSettingsClick() }
}
private fun onCreateRoomClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClick() }
}
private fun onSetUpRecoveryClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSetUpRecoveryClick() }
}
private fun onSessionConfirmRecoveryKeyClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClick() }
}
private fun onRoomSettingsClick(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClick(roomId) }
}
private fun onReportRoomClick(roomId: RoomId) {
backstack.push(NavTarget.ReportRoom(roomId))
}
private fun onDeclineInviteAndBlockUserClick(roomSummary: RoomListRoomSummary) {
backstack.push(NavTarget.DeclineInviteAndBlockUser(roomSummary.toInviteData()))
}
private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) {
when (roomListMenuAction) {
RoomListMenuAction.InviteFriends -> {
inviteFriendsUseCase.execute(activity)
}
RoomListMenuAction.ReportBug -> {
plugins<RoomListEntryPoint.Callback>().forEach { it.onReportBugClick() }
}
}
}
fun rootNode(buildContext: BuildContext): Node {
return node(buildContext) { modifier ->
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
RoomListView(
state = state,
onRoomClick = this::onRoomClick,
onSettingsClick = this::onOpenSettings,
onCreateRoomClick = this::onCreateRoomClick,
onSetUpRecoveryClick = this::onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
onRoomSettingsClick = this::onRoomSettingsClick,
onMenuActionClick = { onMenuActionClick(activity, it) },
onReportRoomClick = this::onReportRoomClick,
onDeclineInviteAndBlockUser = this::onDeclineInviteAndBlockUserClick,
modifier = modifier,
) {
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInviteSuccess = this::onRoomClick,
onDeclineInviteSuccess = { },
modifier = Modifier
)
}
directLogoutView.Render(state.directLogoutState)
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.ReportRoom -> reportRoomEntryPoint.createNode(this, buildContext, navTarget.roomId)
is NavTarget.DeclineInviteAndBlockUser -> declineInviteAndBlockUserEntryPoint.createNode(this, buildContext, navTarget.inviteData)
NavTarget.Root -> rootNode(buildContext)
}
}
}

View file

@ -1,111 +0,0 @@
/*
* Copyright 2023, 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.roomlist.impl
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.invite.api.response.AcceptDeclineInviteView
import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.libraries.deeplink.usecase.InviteFriendsUseCase
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
class RoomListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomListPresenter,
private val inviteFriendsUseCase: InviteFriendsUseCase,
private val analyticsService: AnalyticsService,
private val acceptDeclineInviteView: AcceptDeclineInviteView,
private val directLogoutView: DirectLogoutView,
) : Node(buildContext, plugins = plugins) {
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.Home))
}
)
}
private fun onRoomClick(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomClick(roomId) }
}
private fun onOpenSettings() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSettingsClick() }
}
private fun onCreateRoomClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClick() }
}
private fun onSetUpRecoveryClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSetUpRecoveryClick() }
}
private fun onSessionConfirmRecoveryKeyClick() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClick() }
}
private fun onRoomSettingsClick(roomId: RoomId) {
plugins<RoomListEntryPoint.Callback>().forEach { it.onRoomSettingsClick(roomId) }
}
private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) {
when (roomListMenuAction) {
RoomListMenuAction.InviteFriends -> {
inviteFriendsUseCase.execute(activity)
}
RoomListMenuAction.ReportBug -> {
plugins<RoomListEntryPoint.Callback>().forEach { it.onReportBugClick() }
}
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = requireNotNull(LocalActivity.current)
RoomListView(
state = state,
onRoomClick = this::onRoomClick,
onSettingsClick = this::onOpenSettings,
onCreateRoomClick = this::onCreateRoomClick,
onSetUpRecoveryClick = this::onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick,
onRoomSettingsClick = this::onRoomSettingsClick,
onMenuActionClick = { onMenuActionClick(activity, it) },
modifier = modifier,
) {
acceptDeclineInviteView.Render(
state = state.acceptDeclineInviteState,
onAcceptInvite = this::onRoomClick,
onDeclineInvite = { },
modifier = Modifier
)
}
directLogoutView.Render(state.directLogoutState)
}
}

View file

@ -7,7 +7,6 @@
package io.element.android.features.roomlist.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@ -24,17 +23,17 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction
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.leaveroom.api.LeaveRoomEvent
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.AcceptInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents.DeclineInvite
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomEvent.ShowConfirmation
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.search.RoomListSearchEvents
import io.element.android.features.roomlist.impl.search.RoomListSearchState
import io.element.android.libraries.architecture.AsyncData
@ -124,6 +123,7 @@ class RoomListPresenter @Inject constructor(
}.collectAsState(initial = false)
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
val declineInviteMenu = remember { mutableStateOf<RoomListState.DeclineInviteMenu>(RoomListState.DeclineInviteMenu.Hidden) }
val directLogoutState = logoutPresenter.present()
@ -141,20 +141,22 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.HideContextMenu -> {
contextMenu.value = RoomListState.ContextMenu.Hidden
}
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.setRoomIsFavorite(event.roomId, event.isFavorite)
is RoomListEvents.MarkAsRead -> coroutineScope.markAsRead(event.roomId)
is RoomListEvents.MarkAsUnread -> coroutineScope.markAsUnread(event.roomId)
is RoomListEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(event.roomListRoomSummary.toInviteData())
AcceptInvite(event.roomSummary.toInviteData())
)
}
is RoomListEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(event.roomListRoomSummary.toInviteData())
DeclineInvite(event.roomSummary.toInviteData(), blockUser = event.blockUser, shouldConfirm = false)
)
}
is RoomListEvents.ShowDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Shown(event.roomSummary)
RoomListEvents.HideDeclineInviteMenu -> declineInviteMenu.value = RoomListState.DeclineInviteMenu.Hidden
is RoomListEvents.ClearCacheOfRoom -> coroutineScope.clearCacheOfRoom(event.roomId)
}
}
@ -169,6 +171,7 @@ class RoomListPresenter @Inject constructor(
snackbarMessage = snackbarMessage,
hasNetworkConnection = isOnline,
contextMenu = contextMenu.value,
declineInviteMenu = declineInviteMenu.value,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
canReportBug = canReportBug,
@ -177,6 +180,7 @@ class RoomListPresenter @Inject constructor(
acceptDeclineInviteState = acceptDeclineInviteState,
directLogoutState = directLogoutState,
hideInvitesAvatars = hideInvitesAvatar,
canReportRoom = MatrixConfiguration.CAN_REPORT_ROOM,
eventSink = ::handleEvents,
)
}
@ -253,18 +257,18 @@ class RoomListPresenter @Inject constructor(
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm,
isFavorite = event.roomListRoomSummary.isFavorite,
roomId = event.roomSummary.roomId,
roomName = event.roomSummary.name,
isDm = event.roomSummary.isDm,
isFavorite = event.roomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent,
hasNewContent = event.roomSummary.hasNewContent,
eventCacheFeatureFlagEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first() &&
featureFlagService.isFeatureEnabled(FeatureFlags.EventCache),
)
contextMenuState.value = initialState
client.getRoom(event.roomListRoomSummary.roomId)?.use { room ->
client.getRoom(event.roomSummary.roomId)?.use { room ->
val isShowingContextMenuFlow = snapshotFlow { contextMenuState.value is RoomListState.ContextMenu.Shown }
.distinctUntilChanged()
@ -342,14 +346,3 @@ class RoomListPresenter @Inject constructor(
}
}
}
@VisibleForTesting
internal fun RoomListRoomSummary.toInviteData(): InviteData? {
if (inviteSender == null) return null
return InviteData(
roomId = roomId,
roomName = name ?: roomId.value,
isDm = isDm,
senderId = inviteSender.userId,
)
}

View file

@ -8,7 +8,7 @@
package io.element.android.features.roomlist.impl
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
@ -28,6 +28,7 @@ data class RoomListState(
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val contextMenu: ContextMenu,
val declineInviteMenu: DeclineInviteMenu,
val leaveRoomState: LeaveRoomState,
val filtersState: RoomListFiltersState,
val canReportBug: Boolean,
@ -36,6 +37,7 @@ data class RoomListState(
val acceptDeclineInviteState: AcceptDeclineInviteState,
val directLogoutState: DirectLogoutState,
val hideInvitesAvatars: Boolean,
val canReportRoom: Boolean,
val eventSink: (RoomListEvents) -> Unit,
) {
val displayFilters = contentState is RoomListContentState.Rooms
@ -53,6 +55,11 @@ data class RoomListState(
val hasNewContent: Boolean,
) : ContextMenu
}
sealed interface DeclineInviteMenu {
data object Hidden : DeclineInviteMenu
data class Shown(val roomSummary: RoomListRoomSummary) : DeclineInviteMenu
}
}
enum class SecurityBannerState {

View file

@ -8,8 +8,8 @@
package io.element.android.features.roomlist.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.acceptdecline.AcceptDeclineInviteState
import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState
import io.element.android.features.logout.api.direct.DirectLogoutState
@ -54,6 +54,7 @@ internal fun aRoomListState(
hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null,
contextMenu: RoomListState.ContextMenu = RoomListState.ContextMenu.Hidden,
declineInviteMenu: RoomListState.DeclineInviteMenu = RoomListState.DeclineInviteMenu.Hidden,
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
searchState: RoomListSearchState = aRoomListSearchState(),
filtersState: RoomListFiltersState = aRoomListFiltersState(),
@ -62,6 +63,7 @@ internal fun aRoomListState(
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
directLogoutState: DirectLogoutState = aDirectLogoutState(),
hideInvitesAvatars: Boolean = false,
canReportRoom: Boolean = true,
eventSink: (RoomListEvents) -> Unit = {}
) = RoomListState(
matrixUser = matrixUser,
@ -69,6 +71,7 @@ internal fun aRoomListState(
hasNetworkConnection = hasNetworkConnection,
snackbarMessage = snackbarMessage,
contextMenu = contextMenu,
declineInviteMenu = declineInviteMenu,
leaveRoomState = leaveRoomState,
filtersState = filtersState,
canReportBug = canReportBug,
@ -77,6 +80,7 @@ internal fun aRoomListState(
acceptDeclineInviteState = acceptDeclineInviteState,
directLogoutState = directLogoutState,
hideInvitesAvatars = hideInvitesAvatars,
canReportRoom = canReportRoom,
eventSink = eventSink,
)

View file

@ -49,6 +49,8 @@ fun RoomListView(
onCreateRoomClick: () -> Unit,
onRoomSettingsClick: (roomId: RoomId) -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onReportRoomClick: (roomId: RoomId) -> Unit,
onDeclineInviteAndBlockUser: (roomSummary: RoomListRoomSummary) -> Unit,
modifier: Modifier = Modifier,
acceptDeclineInviteView: @Composable () -> Unit,
) {
@ -60,8 +62,18 @@ fun RoomListView(
if (state.contextMenu is RoomListState.ContextMenu.Shown) {
RoomListContextMenu(
contextMenu = state.contextMenu,
canReportRoom = state.canReportRoom,
eventSink = state.eventSink,
onRoomSettingsClick = onRoomSettingsClick,
onReportRoomClick = onReportRoomClick,
)
}
if (state.declineInviteMenu is RoomListState.DeclineInviteMenu.Shown) {
RoomListDeclineInviteMenu(
menu = state.declineInviteMenu,
canReportRoom = state.canReportRoom,
eventSink = state.eventSink,
onDeclineAndBlockClick = onDeclineInviteAndBlockUser,
)
}
@ -177,7 +189,9 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
onConfirmRecoveryKeyClick = {},
onCreateRoomClick = {},
onRoomSettingsClick = {},
onReportRoomClick = {},
onMenuActionClick = {},
onDeclineInviteAndBlockUser = {},
acceptDeclineInviteView = {},
)
}

View file

@ -104,7 +104,7 @@ internal fun RoomSummaryRow(
eventSink(RoomListEvents.AcceptInvite(room))
},
onDeclineClick = {
eventSink(RoomListEvents.DeclineInvite(room))
eventSink(RoomListEvents.ShowDeclineInviteMenu(room))
}
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.features.roomlist.impl.model
import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
@ -45,4 +46,10 @@ data class RoomListRoomSummary(
numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 ||
isMarkedUnread
fun toInviteData() = InviteData(
roomId = roomId,
roomName = name ?: roomId.value,
isDm = isDm,
)
}

View file

@ -31,8 +31,10 @@ class RoomListContextMenuTest {
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = false,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(R.string.screen_roomlist_mark_as_read)
@ -51,8 +53,10 @@ class RoomListContextMenuTest {
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = false,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(R.string.screen_roomlist_mark_as_unread)
@ -64,26 +68,6 @@ class RoomListContextMenuTest {
)
}
@Test
fun `clicking on Leave dm generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = true)
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_leave_conversation)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideContextMenu,
RoomListEvents.LeaveRoom(contextMenu.roomId),
)
)
}
@Test
fun `clicking on Leave room generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
@ -91,8 +75,10 @@ class RoomListContextMenuTest {
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = false,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_leave_room)
@ -104,6 +90,25 @@ class RoomListContextMenuTest {
)
}
@Test
fun `clicking on Report room invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = true,
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = callback,
)
}
rule.clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess()
}
@Test
fun `clicking on Settings invokes the expected callback and generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
@ -112,8 +117,10 @@ class RoomListContextMenuTest {
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = false,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.common_settings)
@ -129,8 +136,10 @@ class RoomListContextMenuTest {
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = false,
eventSink = eventsRecorder,
onRoomSettingsClick = callback,
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.common_favourite)

View file

@ -0,0 +1,100 @@
/*
* 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.roomlist.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.model.aRoomListRoomSummary
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomListDeclineInviteMenuTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on decline emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(
RoomListEvents.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = false),
)
)
}
@Test
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = true,
onDeclineAndBlockClick = EnsureCalledOnceWithParam(menu.roomSummary, Unit),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(RoomListEvents.HideDeclineInviteMenu)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_decline_and_block)
val expectedEvents = listOf(
RoomListEvents.HideDeclineInviteMenu,
RoomListEvents.DeclineInvite(menu.roomSummary, blockUser = true),
)
eventsRecorder.assertList(expectedEvents)
}
@Test
fun `clicking on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent {
RoomListDeclineInviteMenu(
menu = menu,
canReportRoom = false,
onDeclineAndBlockClick = EnsureNeverCalledWithParam(),
eventSink = eventsRecorder,
)
}
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(listOf(RoomListEvents.HideDeclineInviteMenu))
}
}

View file

@ -13,9 +13,9 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction
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.anAcceptDeclineInviteState
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.acceptdecline.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState
@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
@ -577,7 +578,8 @@ class RoomListPresenterTest {
roomListService = roomListService,
)
val roomSummary = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
currentUserMembership = CurrentUserMembership.INVITED,
inviter = aRoomMember(),
)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
roomListService.postAllRooms(listOf(roomSummary))
@ -593,16 +595,16 @@ class RoomListPresenterTest {
val roomListRoomSummary = state.contentAsRooms().summaries.first {
it.id == roomSummary.roomId.value
}
state.eventSink(RoomListEvents.AcceptInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary))
state.eventSink(RoomListEvents.DeclineInvite(roomListRoomSummary, blockUser = false))
val inviteData = roomListRoomSummary.toInviteData()
assert(eventSinkRecorder)
.isCalledExactly(2)
.withSequence(
listOf(value(AcceptDeclineInviteEvents.AcceptInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData))),
listOf(value(AcceptDeclineInviteEvents.DeclineInvite(inviteData, blockUser = false, shouldConfirm = false))),
)
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@ -226,7 +227,10 @@ class RoomListViewTest {
rule.clickOn(CommonStrings.action_accept)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertList(
listOf(RoomListEvents.AcceptInvite(invitedRoom), RoomListEvents.DeclineInvite(invitedRoom)),
listOf(
RoomListEvents.AcceptInvite(invitedRoom),
RoomListEvents.ShowDeclineInviteMenu(invitedRoom),
)
)
}
}
@ -240,6 +244,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onCreateRoomClick: () -> Unit = EnsureNeverCalled(),
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomListView(
@ -251,6 +257,8 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onCreateRoomClick = onCreateRoomClick,
onRoomSettingsClick = onRoomSettingsClick,
onMenuActionClick = onMenuActionClick,
onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser,
onReportRoomClick = onReportRoomClick,
acceptDeclineInviteView = { },
)
}