Display banned users in room member list (#2415)

* Display banned users in room member list

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-02-20 10:07:06 +01:00
committed by GitHub
parent cc721b6c87
commit c5dcd419ce
34 changed files with 197 additions and 31 deletions

View File

@@ -1,3 +1,4 @@
Add moderation to rooms:
- Sort member in room member list by powerlevel, display their roles.
- Display banner users in room member list for users with enough power level to ban/unban.

View File

@@ -27,14 +27,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncData
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.designsystem.theme.components.SearchBarResultState
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.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.roomMembers
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import javax.inject.Inject
@@ -57,6 +61,20 @@ class RoomMemberListPresenter @Inject constructor(
value = room.canInvite().getOrElse { false }
}
val canDisplayBannedUsers by produceState(initialValue = false) {
val roomIsNotDmAndUserCanBan = !room.isDm && room.canBan().getOrElse { false }
if (roomIsNotDmAndUserCanBan) {
room.membersStateFlow
.onEach { members ->
val hasBannedUsers = members.roomMembers()?.any { it.membership == RoomMembershipState.BAN }.orFalse()
value = hasBannedUsers
}
.collect()
} else {
value = false
}
}
LaunchedEffect(membersState) {
if (membersState is MatrixRoomMembersState.Unknown) {
return@LaunchedEffect
@@ -69,6 +87,7 @@ class RoomMemberListPresenter @Inject constructor(
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
)
)
}
@@ -89,6 +108,7 @@ class RoomMemberListPresenter @Inject constructor(
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()).sortedBy { it.userId.value }.toImmutableList(),
)
)
}
@@ -102,6 +122,7 @@ class RoomMemberListPresenter @Inject constructor(
searchResults = searchResults,
isSearchActive = isSearchActive,
canInvite = canInvite,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { event ->
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active

View File

@@ -27,10 +27,12 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val canDisplayBannedUsers: Boolean,
val eventSink: (RoomMemberListEvents) -> Unit,
)
data class RoomMembers(
val invited: ImmutableList<RoomMember>,
val joined: ImmutableList<RoomMember>
val joined: ImmutableList<RoomMember>,
val banned: ImmutableList<RoomMember>,
)

View File

@@ -32,6 +32,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
banned = persistentListOf(),
)
)
),
@@ -47,6 +48,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
RoomMembers(
invited = persistentListOf(aVictor()),
joined = persistentListOf(anAlice()),
banned = persistentListOf(),
)
),
),
@@ -55,18 +57,30 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchQuery = "something-with-no-results",
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(
roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(),
canDisplayBannedUsers: Boolean = false,
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
searchResults = searchResults,
isSearchActive = false,
canInvite = false,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = {}
)

View File

@@ -16,6 +16,8 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -30,7 +32,12 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
@@ -49,6 +56,7 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@@ -58,6 +66,12 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private enum class SelectedSection {
MEMBERS,
BANNED
}
@Composable
fun RoomMemberListView(
@@ -66,6 +80,7 @@ fun RoomMemberListView(
onInvitePressed: () -> Unit,
onMemberSelected: (UserId) -> Unit,
modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0,
) {
fun onUserSelected(roomMember: RoomMember) {
onMemberSelected(roomMember.userId)
@@ -83,6 +98,7 @@ fun RoomMemberListView(
}
}
) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
Column(
modifier = Modifier
.fillMaxWidth()
@@ -98,7 +114,8 @@ fun RoomMemberListView(
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onUserSelected = ::onUserSelected,
modifier = Modifier.fillMaxWidth()
selectedSection = selectedSection,
modifier = Modifier.fillMaxWidth(),
)
if (!state.isSearchActive) {
@@ -106,7 +123,10 @@ fun RoomMemberListView(
RoomMemberList(
roomMembers = state.roomMembers.data,
showMembersCount = true,
onUserSelected = ::onUserSelected
canDisplayBannedUsersControls = state.canDisplayBannedUsers,
selectedSection = selectedSection,
onSelectedSectionChanged = { selectedSection = it },
onUserSelected = ::onUserSelected,
)
} else if (state.roomMembers.isLoading()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -118,49 +138,90 @@ fun RoomMemberListView(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun RoomMemberList(
roomMembers: RoomMembers,
showMembersCount: Boolean,
selectedSection: SelectedSection,
onSelectedSectionChanged: (SelectedSection) -> Unit,
canDisplayBannedUsersControls: Boolean,
onUserSelected: (RoomMember) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
if (roomMembers.invited.isNotEmpty()) {
roomMemberListSection(
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
members = roomMembers.invited,
onMemberSelected = { onUserSelected(it) }
)
}
if (roomMembers.joined.isNotEmpty()) {
roomMemberListSection(
headerText = {
if (showMembersCount) {
val memberCount = roomMembers.joined.count()
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
} else {
stringResource(id = R.string.screen_room_member_list_room_members_header_title)
if (canDisplayBannedUsersControls) {
stickyHeader {
val segmentedButtonTitles = persistentListOf(
stringResource(id = R.string.screen_room_member_list_mode_members),
stringResource(id = R.string.screen_room_member_list_mode_banned),
)
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
for ((index, title) in segmentedButtonTitles.withIndex()) {
SegmentedButton(
index = index,
count = segmentedButtonTitles.size,
selected = selectedSection.ordinal == index,
onClick = { onSelectedSectionChanged(SelectedSection.entries[index]) },
text = title,
)
}
},
members = roomMembers.joined,
onMemberSelected = { onUserSelected(it) }
)
}
}
}
when (selectedSection) {
SelectedSection.MEMBERS -> {
if (roomMembers.invited.isNotEmpty()) {
roomMemberListSection(
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
members = roomMembers.invited,
onMemberSelected = { onUserSelected(it) }
)
}
if (roomMembers.joined.isNotEmpty()) {
roomMemberListSection(
headerText = {
if (showMembersCount) {
val memberCount = roomMembers.joined.count()
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
} else {
stringResource(id = R.string.screen_room_member_list_room_members_header_title)
}
},
members = roomMembers.joined,
onMemberSelected = { onUserSelected(it) }
)
}
}
SelectedSection.BANNED -> { // Banned users
roomMemberListSection(
headerText = null,
members = roomMembers.banned,
onMemberSelected = { onUserSelected(it) }
)
}
}
}
}
private fun LazyListScope.roomMemberListSection(
headerText: @Composable () -> String,
headerText: @Composable (() -> String)?,
members: ImmutableList<RoomMember>,
onMemberSelected: (RoomMember) -> Unit,
) {
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = headerText(),
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
)
headerText?.let {
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = it(),
style = ElementTheme.typography.fontBodyLgRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
items(members) { matrixUser ->
RoomMemberListItem(
@@ -238,6 +299,7 @@ private fun RoomMemberSearchBar(
onActiveChanged: (Boolean) -> Unit,
onTextChanged: (String) -> Unit,
onUserSelected: (RoomMember) -> Unit,
selectedSection: SelectedSection,
modifier: Modifier = Modifier,
) {
SearchBar(
@@ -252,7 +314,10 @@ private fun RoomMemberSearchBar(
RoomMemberList(
roomMembers = results,
showMembersCount = false,
onUserSelected = { onUserSelected(it) }
onUserSelected = { onUserSelected(it) },
canDisplayBannedUsersControls = false,
selectedSection = selectedSection,
onSelectedSectionChanged = {},
)
},
)
@@ -268,3 +333,28 @@ internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider
onInvitePressed = {},
)
}
@PreviewsDayNight
@Composable
internal fun RoomMemberBannedListPreview() = ElementPreview {
RoomMemberListView(
initialSelectedSectionIndex = 1,
state = aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice"),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob"),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie"),
),
)
),
canDisplayBannedUsers = true,
),
onBackPressed = {},
onMemberSelected = {},
onInvitePressed = {},
)
}

View File

@@ -24,6 +24,8 @@
<string name="screen_room_details_notification_title">"Notifications"</string>
<string name="screen_room_details_share_room_title">"Share room"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_mode_banned">"Banned"</string>
<string name="screen_room_member_list_mode_members">"Members"</string>
<string name="screen_room_member_list_pending_header_title">"Pending"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>

View File

@@ -6,6 +6,7 @@
<string name="screen_session_verification_compare_numbers_subtitle">"Confirm that the numbers below match those shown on your other session."</string>
<string name="screen_session_verification_compare_numbers_title">"Compare numbers"</string>
<string name="screen_session_verification_complete_subtitle">"Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted."</string>
<string name="screen_session_verification_enter_recovery_key">"Enter recovery key"</string>
<string name="screen_session_verification_open_existing_session_subtitle">"Prove its you in order to access your encrypted message history."</string>
<string name="screen_session_verification_open_existing_session_title">"Open an existing session"</string>
<string name="screen_session_verification_positive_button_canceled">"Retry verification"</string>

View File

@@ -129,6 +129,8 @@ interface MatrixRoom : Closeable {
suspend fun canUserInvite(userId: UserId): Result<Boolean>
suspend fun canUserBan(userId: UserId): Result<Boolean>
suspend fun canUserRedactOwn(userId: UserId): Result<Boolean>
suspend fun canUserRedactOther(userId: UserId): Result<Boolean>

View File

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

View File

@@ -319,6 +319,12 @@ class RustMatrixRoom(
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserBan(userId.value)
}
}
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> {
return runCatching {
innerRoom.canUserRedactOwn(userId.value)

View File

@@ -90,6 +90,7 @@ class FakeMatrixRoom(
private var joinRoomResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private var canBanResult = Result.success(false)
private var canRedactOwnResult = Result.success(canRedactOwn)
private var canRedactOtherResult = Result.success(canRedactOther)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
@@ -280,6 +281,10 @@ class FakeMatrixRoom(
inviteUserResult
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> {
return canBanResult
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> {
return canInviteResult
}
@@ -495,6 +500,10 @@ class FakeMatrixRoom(
joinRoomResult = result
}
fun givenCanBanResult(result: Result<Boolean>) {
canBanResult = result
}
fun givenInviteUserResult(result: Result<Unit>) {
inviteUserResult = result
}

View File

@@ -120,6 +120,7 @@
<string name="common_error">"Error"</string>
<string name="common_everyone">"Everyone"</string>
<string name="common_favourite">"Favourite"</string>
<string name="common_favourited">"Favourited"</string>
<string name="common_file">"File"</string>
<string name="common_file_saved_on_disk_android">"File saved to Downloads"</string>
<string name="common_forward_message">"Forward message"</string>