Room member moderation: kick, ban and unban (#2496)

* Room member moderation: kick, ban and unban

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-03-06 16:44:05 +01:00 committed by GitHub
parent 22715f4ea3
commit d8f9408cdb
113 changed files with 1410 additions and 83 deletions

1
changelog.d/2258.feature Normal file
View file

@ -0,0 +1 @@
Room member moderation: kick, ban and unban users from a room.

View file

@ -16,7 +16,10 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents { sealed interface RoomMemberListEvents {
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
} }

View file

@ -35,15 +35,16 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomMemberListNode @AssistedInject constructor( class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter, presenterFactory: RoomMemberListPresenter.Factory,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins), RoomMemberListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun openRoomMemberDetails(roomMemberId: UserId) fun openRoomMemberDetails(roomMemberId: UserId)
fun openInviteMembers() fun openInviteMembers()
} }
private val callbacks = plugins<Callback>() private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(this)
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -53,27 +54,35 @@ class RoomMemberListNode @AssistedInject constructor(
) )
} }
private fun openRoomMemberDetails(roomMemberId: UserId) { override fun openRoomMemberDetails(roomMemberId: UserId) {
callbacks.forEach { callbacks.forEach {
it.openRoomMemberDetails(roomMemberId) it.openRoomMemberDetails(roomMemberId)
} }
} }
private fun openInviteMembers() { override fun openInviteMembers() {
callbacks.forEach { callbacks.forEach {
it.openInviteMembers() it.openInviteMembers()
} }
} }
override fun exitRoomMemberList() {
navigateUp()
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
RoomMemberListView( RoomMemberListView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackPressed = { navigateUp() }, navigator = this,
onMemberSelected = this::openRoomMemberDetails,
onInvitePressed = this::openInviteMembers,
) )
} }
} }
interface RoomMemberListNavigator {
fun exitRoomMemberList() {}
fun openRoomMemberDetails(roomMemberId: UserId) {}
fun openInviteMembers() {}
}

View file

@ -23,32 +23,45 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomMemberListPresenter @Inject constructor( class RoomMemberListPresenter @AssistedInject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource, private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val featureFlagService: FeatureFlagService,
private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
@AssistedFactory
interface Factory {
fun create(navigator: RoomMemberListNavigator): RoomMemberListPresenter
}
@Composable @Composable
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope()
var roomMembers by remember { mutableStateOf<AsyncData<RoomMembers>>(AsyncData.Loading()) } var roomMembers by remember { mutableStateOf<AsyncData<RoomMembers>>(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
@ -61,18 +74,14 @@ class RoomMemberListPresenter @Inject constructor(
value = room.canInvite().getOrElse { false } value = room.canInvite().getOrElse { false }
} }
val canDisplayBannedUsers by produceState(initialValue = false) { val isRoomModerationEnabled by produceState(initialValue = false) {
val roomIsNotDmAndUserCanBan = !room.isDm && room.canBan().getOrElse { false } value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
if (roomIsNotDmAndUserCanBan) {
room.membersStateFlow
.onEach { members ->
val hasBannedUsers = members.roomMembers()?.any { it.membership == RoomMembershipState.BAN }.orFalse()
value = hasBannedUsers
} }
.collect()
val roomModerationState = if (isRoomModerationEnabled) {
roomMembersModerationPresenter.present()
} else { } else {
value = false remember { roomMembersModerationPresenter.dummyState() }
}
} }
LaunchedEffect(membersState) { LaunchedEffect(membersState) {
@ -116,19 +125,28 @@ class RoomMemberListPresenter @Inject constructor(
} }
} }
fun handleEvents(event: RoomMemberListEvents) {
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected -> coroutineScope.launch {
if (roomMembersModerationPresenter.canDisplayModerationActions()) {
roomModerationState.eventSink(RoomMembersModerationEvents.SelectRoomMember(event.roomMember))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
}
}
}
}
return RoomMemberListState( return RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
searchQuery = searchQuery, searchQuery = searchQuery,
searchResults = searchResults, searchResults = searchResults,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
canInvite = canInvite, canInvite = canInvite,
canDisplayBannedUsers = canDisplayBannedUsers, moderationState = roomModerationState,
eventSink = { event -> eventSink = { handleEvents(it) },
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
}
},
) )
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -27,7 +28,7 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<RoomMembers>, val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val canDisplayBannedUsers: Boolean, val moderationState: RoomMembersModerationState,
val eventSink: (RoomMemberListEvents) -> Unit, val eventSink: (RoomMemberListEvents) -> Unit,
) )

View file

@ -17,6 +17,8 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -57,30 +59,20 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound() searchResults = SearchBarResultState.NoResultsFound()
), ),
aRoomMemberListState().copy(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
banned = persistentListOf(),
)
),
canDisplayBannedUsers = true,
),
) )
} }
internal fun aRoomMemberListState( internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized, roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(), searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(),
canDisplayBannedUsers: Boolean = false, moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
searchQuery = "", searchQuery = "",
searchResults = searchResults, searchResults = searchResults,
isSearchActive = false, isSearchActive = false,
canInvite = false, canInvite = false,
canDisplayBannedUsers = canDisplayBannedUsers, moderationState = moderationState,
eventSink = {} eventSink = {}
) )

View file

@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -46,6 +47,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.BackButton
@ -76,14 +79,12 @@ private enum class SelectedSection {
@Composable @Composable
fun RoomMemberListView( fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
onBackPressed: () -> Unit, navigator: RoomMemberListNavigator,
onInvitePressed: () -> Unit,
onMemberSelected: (UserId) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0, initialSelectedSectionIndex: Int = 0,
) { ) {
fun onUserSelected(roomMember: RoomMember) { fun onUserSelected(roomMember: RoomMember) {
onMemberSelected(roomMember.userId) state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
} }
Scaffold( Scaffold(
@ -92,13 +93,18 @@ fun RoomMemberListView(
if (!state.isSearchActive) { if (!state.isSearchActive) {
RoomMemberListTopBar( RoomMemberListTopBar(
canInvite = state.canInvite, canInvite = state.canInvite,
onBackPressed = onBackPressed, onBackPressed = navigator::exitRoomMemberList,
onInvitePressed = onInvitePressed, onInvitePressed = navigator::openInviteMembers,
) )
} }
} }
) { padding -> ) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) } var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canDisplayBannedUsers && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -123,7 +129,7 @@ fun RoomMemberListView(
RoomMemberList( RoomMemberList(
roomMembers = state.roomMembers.data, roomMembers = state.roomMembers.data,
showMembersCount = true, showMembersCount = true,
canDisplayBannedUsersControls = state.canDisplayBannedUsers, canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
selectedSection = selectedSection, selectedSection = selectedSection,
onSelectedSectionChanged = { selectedSection = it }, onSelectedSectionChanged = { selectedSection = it },
onUserSelected = ::onUserSelected, onUserSelected = ::onUserSelected,
@ -136,6 +142,11 @@ fun RoomMemberListView(
} }
} }
} }
RoomMembersModerationView(
state = state.moderationState,
onDisplayMemberProfile = navigator::openRoomMemberDetails
)
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -328,9 +339,7 @@ private fun RoomMemberSearchBar(
internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview { internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView( RoomMemberListView(
state = state, state = state,
onBackPressed = {}, navigator = object : RoomMemberListNavigator {},
onMemberSelected = {},
onInvitePressed = {},
) )
} }
@ -351,10 +360,8 @@ internal fun RoomMemberBannedListPreview() = ElementPreview {
), ),
) )
), ),
canDisplayBannedUsers = true, moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
), ),
onBackPressed = {}, navigator = object : RoomMemberListNavigator {},
onMemberSelected = {},
onInvitePressed = {},
) )
} }

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.finally
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : RoomMembersModerationPresenter {
private var selectedMember by mutableStateOf<RoomMember?>(null)
private suspend fun canBan() = room.canBan().getOrDefault(false)
private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean {
val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
val isDm = room.isDm && room.isEncrypted
return isRoomModerationEnabled && !isDm && (canBan() || canKick())
}
@Composable
override fun present(): RoomMembersModerationState {
val coroutineScope = rememberCoroutineScope()
var moderationActions by remember { mutableStateOf(persistentListOf<ModerationAction>()) }
val kickUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val banUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val canDisplayBannedUsers by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
}
fun handleEvent(event: RoomMembersModerationEvents) {
when (event) {
is RoomMembersModerationEvents.SelectRoomMember -> {
coroutineScope.launch {
selectedMember = event.roomMember
if (event.roomMember.membership == RoomMembershipState.BAN && canBan()) {
unbanUserAsyncAction.value = AsyncAction.Confirming
} else {
moderationActions = buildList {
add(ModerationAction.DisplayProfile(event.roomMember.userId))
val currentUserMemberPowerLevel = room.userRole(room.sessionId).getOrDefault(RoomMember.Role.USER).powerLevel
if (currentUserMemberPowerLevel > event.roomMember.powerLevel) {
if (canKick()) {
add(ModerationAction.KickUser(event.roomMember.userId))
}
if (canBan()) {
add(ModerationAction.BanUser(event.roomMember.userId))
}
}
}.toPersistentList()
}
}
}
is RoomMembersModerationEvents.KickUser -> {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
}
}
is RoomMembersModerationEvents.BanUser -> {
if (banUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.banUser(it.userId, banUserAsyncAction)
}
} else {
banUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.UnbanUser -> {
if (unbanUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
}
} else {
unbanUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.Reset -> {
selectedMember = null
moderationActions = persistentListOf()
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
}
}
}
return RoomMembersModerationState(
selectedRoomMember = selectedMember,
actions = moderationActions,
kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value,
unbanUserAsyncAction = unbanUserAsyncAction.value,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.kickUser(
userId: UserId,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
room.kickUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.banUser(
userId: UserId,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
room.banUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.unbanUser(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
room.unbanUser(userId).finally { selectedMember = null }
}
private fun <T> CoroutineScope.runActionAndWaitForMembershipChange(action: MutableState<AsyncAction<T>>, block: suspend () -> Result<T>) {
launch(dispatchers.io) {
action.runUpdatingState {
val result = block()
if (result.isSuccess) {
room.membersStateFlow.drop(1).take(1)
}
result
}
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data object UnbanUser : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
interface RoomMembersModerationPresenter : Presenter<RoomMembersModerationState> {
suspend fun canDisplayModerationActions(): Boolean
fun dummyState() = RoomMembersModerationState(
selectedRoomMember = null,
actions = persistentListOf(),
kickUserAsyncAction = AsyncAction.Uninitialized,
banUserAsyncAction = AsyncAction.Uninitialized,
unbanUserAsyncAction = AsyncAction.Uninitialized,
canDisplayBannedUsers = false,
eventSink = {}
)
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RoomMembersModerationState(
val selectedRoomMember: RoomMember?,
val actions: ImmutableList<ModerationAction>,
val kickUserAsyncAction: AsyncAction<Unit>,
val banUserAsyncAction: AsyncAction<Unit>,
val unbanUserAsyncAction: AsyncAction<Unit>,
val canDisplayBannedUsers: Boolean,
val eventSink: (RoomMembersModerationEvents) -> Unit,
)
sealed interface ModerationAction {
data class DisplayProfile(val userId: UserId) : ModerationAction
data class KickUser(val userId: UserId) : ModerationAction
data class BanUser(val userId: UserId) : ModerationAction
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
class RoomMembersModerationStatePreviewProvider : PreviewParameterProvider<RoomMembersModerationState> {
override val values: Sequence<RoomMembersModerationState>
get() = sequenceOf(
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = persistentListOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
ModerationAction.BanUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Failure(Exception("Failed to kick user")),
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Success(Unit),
banUserAsyncAction = AsyncAction.Success(Unit),
unbanUserAsyncAction = AsyncAction.Success(Unit),
),
)
}
fun aRoomMembersModerationState(
selectedRoomMember: RoomMember? = null,
actions: List<ModerationAction> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
banUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canDisplayBannedUsers: Boolean = false,
eventSink: (RoomMembersModerationEvents) -> Unit = {},
) = RoomMembersModerationState(
selectedRoomMember = selectedRoomMember,
actions = actions.toPersistentList(),
kickUserAsyncAction = kickUserAsyncAction,
banUserAsyncAction = banUserAsyncAction,
unbanUserAsyncAction = unbanUserAsyncAction,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = eventSink,
)

View file

@ -0,0 +1,318 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.foundation.layout.Box
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
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
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun RoomMembersModerationView(
state: RoomMembersModerationState,
onDisplayMemberProfile: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
if (state.actions.isNotEmpty()) {
RoomMemberActionsBottomSheet(
roomMember = state.selectedRoomMember,
actions = state.actions,
onActionSelected = { action ->
when (action) {
is ModerationAction.DisplayProfile -> {
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser)
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser)
}
}
},
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
when (val action = state.kickUserAsyncAction) {
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_removing_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to kick user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmitClicked = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_banning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to ban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.unbanUserAsyncAction) {
is AsyncAction.Confirming -> {
state.selectedRoomMember?.let {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_manage_member_unban_title),
content = stringResource(R.string.screen_room_member_list_manage_member_unban_message),
submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action),
onSubmitClicked = { state.eventSink(RoomMembersModerationEvents.UnbanUser) },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to unban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberActionsBottomSheet(
roomMember: RoomMember?,
actions: ImmutableList<ModerationAction>,
onActionSelected: (ModerationAction) -> Unit,
onDismiss: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
if (roomMember != null && actions.isNotEmpty()) {
val bottomSheetState = if (LocalInspectionMode.current) {
sheetStateForPreview()
} else {
rememberModalBottomSheetState(skipPartiallyExpanded = true)
}
ModalBottomSheet(
modifier = Modifier.systemBarsPadding(),
sheetState = bottomSheetState,
onDismissRequest = {
coroutineScope.launch {
bottomSheetState.hide()
onDismiss()
}
},
) {
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
Avatar(
avatarData = AvatarData(
id = roomMember.userId.value,
name = roomMember.displayName,
url = roomMember.avatarUrl,
size = AvatarSize.RoomListManageUser,
),
modifier = Modifier
.padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally)
)
roomMember.displayName?.let {
Text(
text = it,
style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
}
Text(
text = roomMember.userId.toString(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
for (action in actions) {
when (action) {
is ModerationAction.DisplayProfile -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_user_info)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = {
coroutineScope.launch {
onActionSelected(action)
bottomSheetState.hide()
}
}
)
}
is ModerationAction.KickUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
is ModerationAction.BanUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
style = ListItemStyle.Destructive,
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomMembersModerationViewPreview(@PreviewParameter(RoomMembersModerationStatePreviewProvider::class) state: RoomMembersModerationState) {
ElementPreview {
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp)) {
RoomMembersModerationView(
state = state,
onDisplayMemberProfile = {},
)
}
}
}

View file

@ -22,13 +22,21 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListNavigator
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.roomdetails.impl.members.aRoomMemberList import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.aVictor import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.aWalter import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.features.roomdetails.members.moderation.FakeRoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -167,6 +175,51 @@ class RoomMemberListPresenterTests {
assertThat(loadedState.canInvite).isFalse() assertThat(loadedState.canInvite).isFalse()
} }
} }
@Test
fun `present - RoomMemberSelected by default opens the room member details through the navigator`() = runTest {
val navigator = FakeRoomMemberListNavigator()
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = false)
val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
assertThat(navigator.openRoomMemberDetailsCallCount).isEqualTo(1)
}
}
@Test
fun `present - RoomMemberSelected will open the moderation options if the current user can use them`() = runTest {
val navigator = FakeRoomMemberListNavigator()
var selectRoomMemberCallCounts = 0
val capturingState = aRoomMembersModerationState(eventSink = { event ->
if (event is RoomMembersModerationEvents.SelectRoomMember) {
selectRoomMemberCallCounts++
}
})
val moderationPresenter = FakeRoomMembersModerationPresenter(canDisplayModerationActions = true).apply {
givenState(capturingState)
}
val presenter = createPresenter(moderationPresenter = moderationPresenter, navigator = navigator)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
assertThat(selectRoomMemberCallCounts).isEqualTo(1)
}
}
}
private class FakeRoomMemberListNavigator : RoomMemberListNavigator {
var openRoomMemberDetailsCallCount = 0
private set
override fun openRoomMemberDetails(userId: UserId) {
openRoomMemberDetailsCallCount++
}
} }
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@ -182,4 +235,14 @@ private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
matrixRoom: MatrixRoom = FakeMatrixRoom(), matrixRoom: MatrixRoom = FakeMatrixRoom(),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers), roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
) = RoomMemberListPresenter(matrixRoom, roomMemberListDataSource, coroutineDispatchers) featureFlagService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
moderationPresenter: FakeRoomMembersModerationPresenter = FakeRoomMembersModerationPresenter(),
navigator: RoomMemberListNavigator = object : RoomMemberListNavigator { }
) = RoomMemberListPresenter(
room = matrixRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
featureFlagService = featureFlagService,
roomMembersModerationPresenter = moderationPresenter,
navigator = navigator
)

View file

@ -0,0 +1,313 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members.moderation
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.moderation.DefaultRoomMembersModerationPresenter
import io.element.android.features.roomdetails.impl.members.moderation.ModerationAction
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultRoomMembersModerationPresenterTests {
@Test
fun `canDisplayModerationActions - when feature flag is disabled returns false`() = runTest {
val featureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to false))
val presenter = createDefaultRoomMembersModerationPresenter(featureFlagService = featureFlagService)
assertThat(presenter.canDisplayModerationActions()).isFalse()
}
@Test
fun `canDisplayModerationActions - when room is DM is false`() = runTest {
val room = FakeMatrixRoom(isDirect = true, isPublic = true, isOneToOne = true).apply {
givenRoomInfo(aRoomInfo(isDirect = true, isPublic = false, activeMembersCount = 2))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isFalse()
}
@Test
fun `canDisplayModerationActions - when user can kick other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
givenCanKickResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `canDisplayModerationActions - when user can ban other users, FF is enabled and room is not a DM returns true`() = runTest {
val room = FakeMatrixRoom(isDirect = false, isOneToOne = false).apply {
givenCanBanResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
assertThat(presenter.canDisplayModerationActions()).isTrue()
}
@Test
fun `present - SelectRoomMember when the current user has permissions displays member actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
ModerationAction.KickUser(selectedMember.userId),
ModerationAction.BanUser(selectedMember.userId)
)
}
}
}
@Test
fun `present - SelectRoomMember displays only view profile if selected member has same power level as the current user`() = runTest {
val room = FakeMatrixRoom(sessionId = A_USER_ID).apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aRoomMember(A_USER_ID_2, powerLevel = 100L)
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(this.selectedRoomMember).isNotNull()
assertThat(this.selectedRoomMember?.userId).isEqualTo(selectedMember.userId)
assertThat(actions).containsExactly(
ModerationAction.DisplayProfile(selectedMember.userId),
)
}
}
}
@Test
fun `present - SelectRoomMember displays an unban confirmation dialog when the member is banned`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
with(awaitItem()) {
assertThat(selectedRoomMember).isNotNull()
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Confirming)
}
}
}
@Test
fun `present - Kick removes the user`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
skipItems(1)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().kickUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(kickUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - BanUser requires confirmation and then bans the user`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val selectedMember = aVictor()
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
val confirmingState = awaitItem()
assertThat(confirmingState.banUserAsyncAction).isEqualTo(AsyncAction.Confirming)
// Confirm
confirmingState.eventSink(RoomMembersModerationEvents.BanUser)
skipItems(1)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().banUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(banUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - UnbanUser requires confirmation and then unbans the user`() = runTest {
val selectedMember = aRoomMember(A_USER_ID_2, membership = RoomMembershipState.BAN)
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenRoomMembersState(MatrixRoomMembersState.Ready(persistentListOf(selectedMember)))
givenUserRoleResult(Result.success(RoomMember.Role.ADMIN))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Displays confirmation dialog
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(selectedMember))
// Confirms unban
awaitItem().eventSink(RoomMembersModerationEvents.UnbanUser)
assertThat(awaitItem().actions).isEmpty()
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Loading)
with(awaitItem()) {
assertThat(unbanUserAsyncAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(selectedRoomMember).isNull()
}
}
}
@Test
fun `present - Reset removes the selected user and actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
// Displays confirmation dialog
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
// Reset state
awaitItem().eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().selectedRoomMember).isNull()
assertThat(awaitItem().actions).isEmpty()
}
}
@Test
fun `present - Reset resets any async actions`() = runTest {
val room = FakeMatrixRoom().apply {
givenCanKickResult(Result.success(true))
givenCanBanResult(Result.success(true))
givenKickUserResult(Result.failure(Throwable("Eek")))
givenBanUserResult(Result.failure(Throwable("Eek")))
givenUnbanUserResult(Result.failure(Throwable("Eek")))
}
val presenter = createDefaultRoomMembersModerationPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialItem = awaitItem()
// Kick user and fail
awaitItem().eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.KickUser)
skipItems(2)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().kickUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Ban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor()))
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
awaitItem().eventSink(RoomMembersModerationEvents.BanUser)
skipItems(2)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().banUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
// Unban user and fail
initialItem.eventSink(RoomMembersModerationEvents.SelectRoomMember(aVictor().copy(membership = RoomMembershipState.BAN)))
val confirmingState = awaitItem()
assertThat(confirmingState.unbanUserAsyncAction).isInstanceOf(AsyncAction.Confirming::class.java)
confirmingState.eventSink(RoomMembersModerationEvents.UnbanUser)
skipItems(1)
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Loading::class.java)
assertThat(awaitItem().unbanUserAsyncAction).isInstanceOf(AsyncAction.Failure::class.java)
// Reset it
initialItem.eventSink(RoomMembersModerationEvents.Reset)
assertThat(awaitItem().unbanUserAsyncAction).isEqualTo(AsyncAction.Uninitialized)
}
}
private fun TestScope.createDefaultRoomMembersModerationPresenter(
matrixRoom: FakeMatrixRoom = FakeMatrixRoom(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.RoomModeration.key to true)),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
): DefaultRoomMembersModerationPresenter {
return DefaultRoomMembersModerationPresenter(
room = matrixRoom,
featureFlagService = featureFlagService,
dispatchers = dispatchers,
)
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
class FakeRoomMembersModerationPresenter(
private val canDisplayModerationActions: Boolean = true,
) : RoomMembersModerationPresenter {
private var state by mutableStateOf(dummyState())
override suspend fun canDisplayModerationActions(): Boolean {
return canDisplayModerationActions
}
@Composable
override fun present(): RoomMembersModerationState {
return state
}
fun givenState(state: RoomMembersModerationState) {
this.state = state
}
}

View file

@ -20,7 +20,7 @@ For now, you can deselect filters in order to see your other chats"</string>
<string name="screen_roomlist_filter_rooms_empty_state_title">"Youre not in any room yet"</string> <string name="screen_roomlist_filter_rooms_empty_state_title">"Youre not in any room yet"</string>
<string name="screen_roomlist_filter_unreads">"Unreads"</string> <string name="screen_roomlist_filter_unreads">"Unreads"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"Congrats! <string name="screen_roomlist_filter_unreads_empty_state_title">"Congrats!
You dont have any unread message!"</string> You dont have any unread messages!"</string>
<string name="screen_roomlist_main_space_title">"Chats"</string> <string name="screen_roomlist_main_space_title">"Chats"</string>
<string name="screen_roomlist_mark_as_read">"Mark as read"</string> <string name="screen_roomlist_mark_as_read">"Mark as read"</string>
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string> <string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>

View file

@ -79,9 +79,9 @@ sealed interface AsyncAction<out T> {
fun isUninitialized(): Boolean = this == Uninitialized fun isUninitialized(): Boolean = this == Uninitialized
fun isConfirming(): Boolean = this is Confirming fun isConfirming(): Boolean = this == Confirming
fun isLoading(): Boolean = this is Loading fun isLoading(): Boolean = this == Loading
fun isFailure(): Boolean = this is Failure fun isFailure(): Boolean = this is Failure

View file

@ -47,3 +47,9 @@ inline fun <R, T> Result<T>.flatMapCatching(transform: (T) -> Result<R>): Result
onFailure = { Result.failure(it) } onFailure = { Result.failure(it) }
) )
} }
inline fun <T> Result<T>.finally(block: (exception: Throwable?) -> Unit): Result<T> {
onSuccess { block(null) }
onFailure(block)
return this
}

View file

@ -47,6 +47,7 @@ enum class AvatarSize(val dp: Dp) {
InviteSender(16.dp), InviteSender(16.dp),
EditRoomDetails(70.dp), EditRoomDetails(70.dp),
RoomListManageUser(70.dp),
NotificationsOptIn(32.dp), NotificationsOptIn(32.dp),

View file

@ -193,7 +193,7 @@ class AsyncIndicatorTests {
currentAnimationState = TransitionStateSnapshot(transitionState), currentAnimationState = TransitionStateSnapshot(transitionState),
) )
}.test { }.test {
var firstItem: Any? = null var firstItem: Any?
skipItems(1) skipItems(1)
state.enqueue(composable = {}) state.enqueue(composable = {})
state.enqueue(composable = {}) state.enqueue(composable = {})

View file

@ -140,6 +140,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserInvite(userId: UserId): Result<Boolean> suspend fun canUserInvite(userId: UserId): Result<Boolean>
suspend fun canUserKick(userId: UserId): Result<Boolean>
suspend fun canUserBan(userId: UserId): Result<Boolean> suspend fun canUserBan(userId: UserId): Result<Boolean>
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
@ -177,6 +179,12 @@ interface MatrixRoom : Closeable {
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
suspend fun kickUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun banUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun unbanUser(userId: UserId, reason: String? = null): Result<Unit>
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
/** /**

View file

@ -25,6 +25,11 @@ import io.element.android.libraries.matrix.api.room.StateEventType
*/ */
suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId) suspend fun MatrixRoom.canInvite(): Result<Boolean> = canUserInvite(sessionId)
/**
* Shortcut for calling [MatrixRoom.canUserKick] with our own user.
*/
suspend fun MatrixRoom.canKick(): Result<Boolean> = canUserKick(sessionId)
/** /**
* Shortcut for calling [MatrixRoom.canBanUser] with our own user. * Shortcut for calling [MatrixRoom.canBanUser] with our own user.
*/ */

View file

@ -39,6 +39,7 @@ interface MatrixTimeline : AutoCloseable {
val paginationState: StateFlow<PaginationState> val paginationState: StateFlow<PaginationState>
val timelineItems: Flow<List<MatrixTimelineItem>> val timelineItems: Flow<List<MatrixTimelineItem>>
val membershipChangeEventReceived: Flow<Unit>
suspend fun paginateBackwards(requestSize: Int): Result<Unit> suspend fun paginateBackwards(requestSize: Int): Result<Unit>
suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit> suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int): Result<Unit>

View file

@ -70,6 +70,8 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -159,6 +161,12 @@ class RustMatrixRoom(
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow() override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
init {
timeline.membershipChangeEventReceived
.onEach { roomMemberListFetcher.fetchRoomMembers() }
.launchIn(roomCoroutineScope)
}
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId) override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId) override suspend fun unsubscribeFromSync() = roomSyncSubscriber.unsubscribe(roomId)
@ -340,6 +348,12 @@ class RustMatrixRoom(
} }
} }
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserKick(userId.value)
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> { override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return runCatching { return runCatching {
innerRoom.canUserBan(userId.value) innerRoom.canUserBan(userId.value)
@ -469,6 +483,24 @@ class RustMatrixRoom(
} }
} }
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.kickUser(userId.value, reason)
}
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.banUser(userId.value, reason)
}
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.unbanUser(userId.value, reason)
}
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) { override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.setIsFavourite(isFavorite, null) innerRoom.setIsFavourite(isFavorite, null)

View file

@ -65,7 +65,7 @@ internal class RoomMemberListFetcher(
if (_membersFlow.value !is MatrixRoomMembersState.Ready) { if (_membersFlow.value !is MatrixRoomMembersState.Ready) {
fetchCachedRoomMembers() fetchCachedRoomMembers()
} else { } else {
Timber.i("No need to load cached members found for room $roomId") Timber.i("Cached members not found for $roomId")
} }
} }

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -55,6 +56,8 @@ class AsyncMatrixTimeline(
} }
private val closeSignal = CompletableDeferred<Unit>() private val closeSignal = CompletableDeferred<Unit>()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
init { init {
coroutineScope.launch { coroutineScope.launch {
val delegateTimeline = timeline.await() val delegateTimeline = timeline.await()
@ -64,6 +67,9 @@ class AsyncMatrixTimeline(
delegateTimeline.paginationState delegateTimeline.paginationState
.onEach { _paginationState.value = it } .onEach { _paginationState.value = it }
.launchIn(this) .launchIn(this)
delegateTimeline.membershipChangeEventReceived
.onEach { membershipChangeEventReceived.emit(it) }
.launchIn(this)
launch { launch {
withContext(NonCancellable) { withContext(NonCancellable) {

View file

@ -17,6 +17,9 @@
package io.element.android.libraries.matrix.impl.timeline package io.element.android.libraries.matrix.impl.timeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -31,6 +34,9 @@ internal class MatrixTimelineDiffProcessor(
) { ) {
private val mutex = Mutex() private val mutex = Mutex()
private val _membershipChangeEventReceived = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val membershipChangeEventReceived: Flow<Unit> = _membershipChangeEventReceived
suspend fun postItems(items: List<TimelineItem>) { suspend fun postItems(items: List<TimelineItem>) {
updateTimelineItems { updateTimelineItems {
Timber.v("Update timeline items from postItems (with ${items.size} items) on ${Thread.currentThread()}") Timber.v("Update timeline items from postItems (with ${items.size} items) on ${Thread.currentThread()}")
@ -63,6 +69,11 @@ internal class MatrixTimelineDiffProcessor(
} }
TimelineChange.PUSH_BACK -> { TimelineChange.PUSH_BACK -> {
val item = diff.pushBack()?.asMatrixTimelineItem() ?: return val item = diff.pushBack()?.asMatrixTimelineItem() ?: return
if (item is MatrixTimelineItem.Event && item.event.content is RoomMembershipContent) {
// TODO - This is a temporary solution to notify the room screen about membership changes
// Ideally, this should be implemented by the Rust SDK
_membershipChangeEventReceived.tryEmit(Unit)
}
add(item) add(item)
} }
TimelineChange.PUSH_FRONT -> { TimelineChange.PUSH_FRONT -> {

View file

@ -114,6 +114,8 @@ class RustMatrixTimeline(
) )
} }
override val membershipChangeEventReceived: Flow<Unit> = timelineDiffProcessor.membershipChangeEventReceived
init { init {
Timber.d("Initialize timeline for room ${matrixRoom.roomId}") Timber.d("Initialize timeline for room ${matrixRoom.roomId}")

View file

@ -95,6 +95,7 @@ class FakeMatrixRoom(
private var joinRoomResult = Result.success(Unit) private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit) private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true) private var canInviteResult = Result.success(true)
private var canKickResult = Result.success(false)
private var canBanResult = Result.success(false) private var canBanResult = Result.success(false)
private var canRedactOwnResult = Result.success(canRedactOwn) private var canRedactOwnResult = Result.success(canRedactOwn)
private var canRedactOtherResult = Result.success(canRedactOther) private var canRedactOtherResult = Result.success(canRedactOther)
@ -111,6 +112,9 @@ class FakeMatrixRoom(
private var cancelSendResult = Result.success(Unit) private var cancelSendResult = Result.success(Unit)
private var forwardEventResult = Result.success(Unit) private var forwardEventResult = Result.success(Unit)
private var reportContentResult = Result.success(Unit) private var reportContentResult = Result.success(Unit)
private var kickUserResult = Result.success(Unit)
private var banUserResult = Result.success(Unit)
private var unBanUserResult = Result.success(Unit)
private var sendLocationResult = Result.success(Unit) private var sendLocationResult = Result.success(Unit)
private var createPollResult = Result.success(Unit) private var createPollResult = Result.success(Unit)
private var editPollResult = Result.success(Unit) private var editPollResult = Result.success(Unit)
@ -299,6 +303,10 @@ class FakeMatrixRoom(
return canBanResult return canBanResult
} }
override suspend fun canUserKick(userId: UserId): Result<Boolean> {
return canKickResult
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> { override suspend fun canUserInvite(userId: UserId): Result<Boolean> {
return canInviteResult return canInviteResult
} }
@ -398,6 +406,18 @@ class FakeMatrixRoom(
return reportContentResult return reportContentResult
} }
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> {
return kickUserResult
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> {
return banUserResult
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> {
return unBanUserResult
}
val setIsFavoriteCalls = mutableListOf<Boolean>() val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> { override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
@ -522,6 +542,10 @@ class FakeMatrixRoom(
joinRoomResult = result joinRoomResult = result
} }
fun givenCanKickResult(result: Result<Boolean>) {
canKickResult = result
}
fun givenCanBanResult(result: Result<Boolean>) { fun givenCanBanResult(result: Result<Boolean>) {
canBanResult = result canBanResult = result
} }
@ -598,6 +622,18 @@ class FakeMatrixRoom(
reportContentResult = result reportContentResult = result
} }
fun givenKickUserResult(result: Result<Unit>) {
kickUserResult = result
}
fun givenBanUserResult(result: Result<Unit>) {
banUserResult = result
}
fun givenUnbanUserResult(result: Result<Unit>) {
unBanUserResult = result
}
fun givenSendLocationResult(result: Result<Unit>) { fun givenSendLocationResult(result: Result<Unit>) {
sendLocationResult = result sendLocationResult = result
} }

View file

@ -24,6 +24,7 @@ import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.getAndUpdate
@ -59,6 +60,8 @@ class FakeMatrixTimeline(
override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards() override suspend fun paginateBackwards(requestSize: Int) = paginateBackwards()
override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards() override suspend fun paginateBackwards(requestSize: Int, untilNumberOfItems: Int) = paginateBackwards()
override val membershipChangeEventReceived = MutableSharedFlow<Unit>()
private suspend fun paginateBackwards(): Result<Unit> { private suspend fun paginateBackwards(): Result<Unit> {
updatePaginationState { updatePaginationState {
copy(isBackPaginating = true) copy(isBackPaginating = true)
@ -73,6 +76,10 @@ class FakeMatrixTimeline(
return Result.success(Unit) return Result.success(Unit)
} }
fun givenMembershipChangeEventReceived() {
membershipChangeEventReceived.tryEmit(Unit)
}
override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask { override suspend fun fetchDetailsForEvent(eventId: EventId): Result<Unit> = simulateLongTask {
Result.success(Unit) Result.success(Unit)
} }

View file

@ -114,6 +114,7 @@
<string name="common_audio">"Audio"</string> <string name="common_audio">"Audio"</string>
<string name="common_blocked_users">"Blocked users"</string> <string name="common_blocked_users">"Blocked users"</string>
<string name="common_bubbles">"Bubbles"</string> <string name="common_bubbles">"Bubbles"</string>
<string name="common_call_invite">"Call in progress (unsupported)"</string>
<string name="common_chat_backup">"Chat backup"</string> <string name="common_chat_backup">"Chat backup"</string>
<string name="common_copyright">"Copyright"</string> <string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string> <string name="common_creating_room">"Creating room…"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4205303d8fea02149db6825e62e2df8b0ae647452e535c73521aefa43da5d3ae
size 18859

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2b58661c91aa2e718518780ee7b217e5778bea9187060b9a35549c8f3f7b6ab4
size 23198

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e223cda6d004bfd6c5310cd80f1ae3b3004e6e511bf6df55fde1093397562b60
size 28428

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7794f8f1fa3f7d9c7a1f37dae1d41635c6ce99dbb4b64c435e89b1c3bee4d0fd
size 10672

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19afdcbcd76fa24242ddc6b61fed81169cd117db3a3cefe6a41df263be7e9a3e
size 10418

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23bb2d5954ba402203dcb76b88f5da5f788d24d1f16e827671317d723802cfd7
size 10496

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b11aa7970dd9f8e9a6ecb0cedea158e6e7070fd19c4dd3716d460bedebc14977
size 8999

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:80fd575cdce74e271d92fb49258b79af0cfba2826320225830061a2be9b738d4
size 30470

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:68626eb427f6b0435b328110b6735b7a7c39f9e2204cd03742ace3e09609b836
size 23282

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a397f9285a8ed44272d02343ac9418e4e84e359dcac67dad3725cc06449a1b42
size 17574

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66e3caddece8e727af4b6da9382955916ebea52eb78ad814ad4a746827482b3d
size 21646

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6ef8b93ca353aace2fce6cfb1608e58fdcac3ac60619c3b64409427127e27613
size 26419

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4a250c21e73b1b703c4283af4d3d1230fc58d56084792555a7233116919f2195
size 9310

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0079374541069e230f68c164ec266ce09eddb3073a856aedddb68c80e2666dbd
size 8920

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:685bc2a0bd83489a92280da2bd17a44b2a4580493e985aa7809e338807c3fd40
size 9139

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:550d0757162622ffb70750821d7af863647d02e9108686e6d19d9352516094d3
size 7602

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e10ed25980d51632b8e6dc0c5b0f81c9b137d1882d28f3d74caee0fc852beb8a
size 25419

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b4ca16fde118edd01fac50891e4d2154bf79b6d004a50c51270f1480aff5b948
size 19252

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753
size 4464

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efc266bca406673c57731b4d6a283d217e7199f4b8a9c70c45ed3e00611d165d
size 52344

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65dd3de9356356a9bf651cecd725c1be89a0d1b1ba816e4df9b25ce0a76313dd
size 51139

Some files were not shown because too many files have changed in this diff Show more