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:
committed by
GitHub
parent
cc721b6c87
commit
c5dcd419ce
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 it’s 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user