change(members): new empty state for search and hide banned tabs when there is none.

This commit is contained in:
ganfra
2025-11-24 10:42:03 +01:00
parent a3bb1b93ab
commit 8b7355705a
5 changed files with 79 additions and 58 deletions
@@ -49,7 +49,6 @@ import kotlinx.coroutines.withContext
@Inject @Inject
class RoomMemberListPresenter( class RoomMemberListPresenter(
private val room: JoinedRoom, private val room: JoinedRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>, private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
@@ -141,20 +140,21 @@ class RoomMemberListPresenter(
} }
} }
if (!roomModerationState.canBan && selectedSection == SelectedSection.BANNED) { val state = RoomMemberListState(
SideEffect { roomMembers = roomMembers,
selectedSection = SelectedSection.MEMBERS filteredRoomMembers = filteredRoomMembers,
}
}
return RoomMemberListState(
roomMembers = filteredRoomMembers,
searchQuery = searchQuery, searchQuery = searchQuery,
canInvite = canInvite, canInvite = canInvite,
moderationState = roomModerationState, moderationState = roomModerationState,
selectedSection = selectedSection, selectedSection = selectedSection,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
if (!state.showBannedSection && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
return state
} }
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState { private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
@@ -17,13 +17,17 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
data class RoomMemberListState( data class RoomMemberListState(
val roomMembers: AsyncData<RoomMembers>, // Only used to know if we can show the banned section
private val roomMembers: AsyncData<RoomMembers>,
val filteredRoomMembers: AsyncData<RoomMembers>,
val searchQuery: String, val searchQuery: String,
val canInvite: Boolean, val canInvite: Boolean,
val selectedSection: SelectedSection, val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState, val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit, val eventSink: (RoomMemberListEvents) -> Unit,
) ) {
val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection { enum class SelectedSection {
MEMBERS, MEMBERS,
@@ -34,7 +38,14 @@ data class RoomMembers(
val invited: ImmutableList<RoomMemberWithIdentityState>, val invited: ImmutableList<RoomMemberWithIdentityState>,
val joined: ImmutableList<RoomMemberWithIdentityState>, val joined: ImmutableList<RoomMemberWithIdentityState>,
val banned: ImmutableList<RoomMemberWithIdentityState>, val banned: ImmutableList<RoomMemberWithIdentityState>,
){ ) {
fun isEmpty(section: SelectedSection): Boolean {
return when (section) {
SelectedSection.MEMBERS -> invited.isEmpty() && joined.isEmpty()
SelectedSection.BANNED -> banned.isEmpty()
}
}
fun filter(query: String): RoomMembers { fun filter(query: String): RoomMembers {
if (query.isBlank()) { if (query.isBlank()) {
return this return this
@@ -132,6 +132,7 @@ internal fun aRoomMemberListState(
selectedSection: SelectedSection = SelectedSection.MEMBERS, selectedSection: SelectedSection = SelectedSection.MEMBERS,
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
filteredRoomMembers = roomMembers,
searchQuery = "", searchQuery = "",
canInvite = false, canInvite = false,
moderationState = moderationState, moderationState = moderationState,
@@ -9,14 +9,15 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
@@ -43,6 +44,8 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
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
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
@@ -87,9 +90,9 @@ fun RoomMemberListView(
) { padding -> ) { padding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
var searchQuery by textFieldState(state.searchQuery) var searchQuery by textFieldState(state.searchQuery)
@@ -100,15 +103,16 @@ fun RoomMemberListView(
state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery)) state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery))
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
placeholder = stringResource(CommonStrings.common_search_for_someone), placeholder = stringResource(CommonStrings.common_search_for_someone),
) )
RoomMemberList( RoomMemberList(
roomMembers = state.roomMembers, roomMembersData = state.filteredRoomMembers,
selectedSection = state.selectedSection, selectedSection = state.selectedSection,
showBannedSection = state.showBannedSection,
searchQuery = state.searchQuery,
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) }, onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
showSections = state.moderationState.canBan,
onSelectUser = ::onSelectUser, onSelectUser = ::onSelectUser,
) )
} }
@@ -117,25 +121,26 @@ fun RoomMemberListView(
@Composable @Composable
private fun RoomMemberList( private fun RoomMemberList(
roomMembers: AsyncData<RoomMembers>, roomMembersData: AsyncData<RoomMembers>,
selectedSection: SelectedSection, selectedSection: SelectedSection,
showSections: Boolean = true, showBannedSection: Boolean,
searchQuery: String,
onSelectedSectionChange: (SelectedSection) -> Unit, onSelectedSectionChange: (SelectedSection) -> Unit,
onSelectUser: (RoomMember) -> Unit, onSelectUser: (RoomMember) -> Unit,
) { ) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
stickyHeader { stickyHeader {
Column { Column {
if (showSections) { AnimatedVisibility(visible = showBannedSection) {
val segmentedButtonTitles = persistentListOf( val segmentedButtonTitles = persistentListOf(
stringResource(id = R.string.screen_room_member_list_mode_members), stringResource(id = R.string.screen_room_member_list_mode_members),
stringResource(id = R.string.screen_room_member_list_mode_banned), stringResource(id = R.string.screen_room_member_list_mode_banned),
) )
SingleChoiceSegmentedButtonRow( SingleChoiceSegmentedButtonRow(
modifier = Modifier modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault) .background(ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), .padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) { ) {
for ((index, title) in segmentedButtonTitles.withIndex()) { for ((index, title) in segmentedButtonTitles.withIndex()) {
SegmentedButton( SegmentedButton(
@@ -148,23 +153,26 @@ private fun RoomMemberList(
} }
} }
} }
AnimatedVisibility( AnimatedVisibility(visible = roomMembersData.isLoading()) {
visible = roomMembers.isLoading(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} }
} }
} }
when (roomMembers) { when (roomMembersData) {
is AsyncData.Failure -> failureItem(roomMembers.error) is AsyncData.Failure -> failureItem(roomMembersData.error)
is AsyncData.Loading, is AsyncData.Loading,
is AsyncData.Success -> memberItems( is AsyncData.Success -> {
roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn, val roomMembers = roomMembersData.dataOrNull() ?: return@LazyColumn
selectedSection = selectedSection, if (roomMembers.isEmpty(selectedSection)) {
onSelectUser = onSelectUser, emptySearchItem(searchQuery)
) } else {
memberItems(
roomMembers = roomMembers,
selectedSection = selectedSection,
onSelectUser = onSelectUser,
)
}
}
AsyncData.Uninitialized -> Unit AsyncData.Uninitialized -> Unit
} }
} }
@@ -202,7 +210,7 @@ private fun LazyListScope.memberItems(
) )
} }
} }
SelectedSection.BANNED -> { // Banned users SelectedSection.BANNED -> {
if (roomMembers.banned.isNotEmpty()) { if (roomMembers.banned.isNotEmpty()) {
roomMemberListSectionHeader( roomMemberListSectionHeader(
text = { text = {
@@ -215,23 +223,6 @@ private fun LazyListScope.memberItems(
members = roomMembers.banned, members = roomMembers.banned,
onMemberSelected = { onSelectUser(it) } onMemberSelected = { onSelectUser(it) }
) )
} else {
item {
Box(
Modifier
.fillParentMaxSize()
.padding(horizontal = 16.dp)
) {
Text(
modifier = Modifier
.padding(bottom = 56.dp)
.align(Alignment.Center),
text = stringResource(id = R.string.screen_room_member_list_banned_empty),
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
} }
} }
} }
@@ -241,8 +232,8 @@ private fun LazyListScope.failureItem(failure: Throwable) {
item { item {
Text( Text(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp), .padding(horizontal = 16.dp, vertical = 32.dp),
text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage, text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage,
color = ElementTheme.colors.textCriticalPrimary, color = ElementTheme.colors.textCriticalPrimary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -278,6 +269,22 @@ private fun LazyListScope.roomMemberListSectionItems(
} }
} }
private fun LazyListScope.emptySearchItem(searchQuery: String) {
item {
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
iconStyle = BigIcon.Style.Default(
vectorIcon = CompoundIcons.Search(),
contentDescription = null,
),
title = stringResource(R.string.screen_room_member_list_empty_search_title, searchQuery),
subTitle = stringResource(R.string.screen_room_member_list_empty_search_subtitle),
)
}
}
@Composable @Composable
private fun RoomMemberListItem( private fun RoomMemberListItem(
roomMemberWithIdentity: RoomMemberWithIdentityState, roomMemberWithIdentity: RoomMemberWithIdentityState,
@@ -72,6 +72,8 @@
<string name="screen_room_details_updating_room">"Updating room…"</string> <string name="screen_room_details_updating_room">"Updating room…"</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users."</string> <string name="screen_room_member_list_banned_empty">"There are no banned users."</string>
<string name="screen_room_member_list_banned_header_title">"%1$d Banned"</string> <string name="screen_room_member_list_banned_header_title">"%1$d Banned"</string>
<string name="screen_room_member_list_empty_search_subtitle">"Check the spelling or try a new search"</string>
<string name="screen_room_member_list_empty_search_title">"No results for “%1$s”"</string>
<plurals name="screen_room_member_list_header_title"> <plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d Person"</item> <item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d People"</item> <item quantity="other">"%1$d People"</item>