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 1b0beda620
commit 8fd34051e0
5 changed files with 79 additions and 58 deletions

View file

@ -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 {

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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>