change(members): use SearchField and update strings

This commit is contained in:
ganfra 2025-11-21 11:54:07 +01:00
parent 0beeda6001
commit a3bb1b93ab
9 changed files with 113 additions and 226 deletions

View file

@ -35,8 +35,8 @@
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
<string name="screen_room_member_list_banned_empty">"There are no banned users."</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d People"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban user"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
@ -45,7 +45,7 @@
<string name="screen_room_member_list_manage_member_unban_title">"Unban user"</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_pending_header_title">"%1$d Invited"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Owner"</string>

View file

@ -1,47 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomdetails.impl.members
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.BaseRoom
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.roomMembers
import kotlinx.coroutines.withContext
import kotlin.collections.filter
@Inject
class RoomMemberListDataSource(
private val room: BaseRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) {
suspend fun search(query: String, selectedSection: SelectedSection): List<RoomMember> = withContext(coroutineDispatchers.io) {
val roomMembersState = room.membersStateFlow.value
val displayableMembers = roomMembersState.roomMembers()
.orEmpty()
.filter {
when(selectedSection){
SelectedSection.MEMBERS -> it.membership.isActive()
SelectedSection.BANNED -> it.membership == RoomMembershipState.BAN
}
}
val filteredMembers = if (query.isBlank()) {
displayableMembers
} else {
displayableMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) ||
member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}
filteredMembers
}
}

View file

@ -13,6 +13,5 @@ import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents {
data class ChangeSelectedSection(val section: SelectedSection): RoomMemberListEvents
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
}

View file

@ -23,6 +23,7 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.map
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@ -41,7 +42,6 @@ import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
@ -63,8 +63,6 @@ class RoomMemberListPresenter(
var searchResults by remember {
mutableStateOf<SearchBarResultState<AsyncData<RoomMembers>>>(SearchBarResultState.Initial())
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val membersState by room.membersStateFlow.collectAsState()
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
@ -78,8 +76,9 @@ class RoomMemberListPresenter(
.launchIn(this)
}
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading())}
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS)}
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
// Update the room members when the screen is loaded
LaunchedEffect(Unit) {
@ -98,7 +97,7 @@ class RoomMemberListPresenter(
}
withContext(coroutineDispatchers.io) {
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
val info = room.roomInfoFlow.first()
val info = room.info()
if (members.getOrDefault(RoomMembershipState.JOIN, emptyList()).size < info.joinedMembersCount / 2) {
// Don't display initial room member list if we have less than half of the joined members:
// This result will come from the timeline loading membership events and it'll be wrong.
@ -125,43 +124,16 @@ class RoomMemberListPresenter(
}
}
LaunchedEffect(membersState, searchQuery, isSearchActive) {
withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty() || !isSearchActive) {
SearchBarResultState.Initial()
} else {
val results = roomMemberListDataSource.search(searchQuery, selectedSection).groupBy { it.membership }
if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
val result = RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList())
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(powerLevelRoomMemberComparator)
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList())
.sortedBy { it.userId.value }
.map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(),
)
SearchBarResultState.Results(
if (membersState is RoomMembersState.Pending) {
AsyncData.Loading(result)
} else {
AsyncData.Success(result)
}
)
}
LaunchedEffect(searchQuery, roomMembers) {
filteredRoomMembers = roomMembers.map { members ->
withContext(coroutineDispatchers.io) {
members.filter(searchQuery)
}
}
}
fun handleEvent(event: RoomMemberListEvents) {
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected ->
roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
@ -176,10 +148,8 @@ class RoomMemberListPresenter(
}
return RoomMemberListState(
roomMembers = roomMembers,
roomMembers = filteredRoomMembers,
searchQuery = searchQuery,
searchResults = searchResults,
isSearchActive = isSearchActive,
canInvite = canInvite,
moderationState = roomModerationState,
selectedSection = selectedSection,

View file

@ -10,16 +10,15 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class RoomMemberListState(
val roomMembers: AsyncData<RoomMembers>,
val searchQuery: String,
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState,
@ -35,7 +34,22 @@ data class RoomMembers(
val invited: ImmutableList<RoomMemberWithIdentityState>,
val joined: ImmutableList<RoomMemberWithIdentityState>,
val banned: ImmutableList<RoomMemberWithIdentityState>,
)
){
fun filter(query: String): RoomMembers {
if (query.isBlank()) {
return this
}
val filterPredicate = { member: RoomMemberWithIdentityState ->
member.roomMember.userId.value.contains(query, ignoreCase = true) ||
member.roomMember.displayName?.contains(query, ignoreCase = true).orFalse()
}
return RoomMembers(
invited = invited.filter(filterPredicate).toImmutableList(),
joined = joined.filter(filterPredicate).toImmutableList(),
banned = banned.filter(filterPredicate).toImmutableList(),
)
}
}
data class RoomMemberWithIdentityState(
val roomMember: RoomMember,

View file

@ -59,36 +59,21 @@ private fun roomMemberListStates(): Sequence<RoomMemberListState> = sequenceOf(
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = false,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "someone",
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = SearchBarResultState.Results(
AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity()),
joined = persistentListOf(anAlice().withIdentity()),
banned = persistentListOf(),
)
)
),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound(),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
@ -143,17 +128,14 @@ private fun bannedRoomMemberListStates(): Sequence<RoomMemberListState> = sequen
internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
selectedSection: SelectedSection = SelectedSection.MEMBERS,
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
searchResults = searchResults,
isSearchActive = false,
canInvite = false,
moderationState = moderationState,
selectedSection = SelectedSection.MEMBERS,
selectedSection = selectedSection,
eventSink = {}
)

View file

@ -51,8 +51,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
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.SearchField
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
@ -79,44 +78,39 @@ fun RoomMemberListView(
Scaffold(
modifier = modifier,
topBar = {
if (!state.isSearchActive) {
RoomMemberListTopBar(
canInvite = state.canInvite,
onBackClick = navigator::exitRoomMemberList,
onInviteClick = navigator::openInviteMembers,
)
}
RoomMemberListTopBar(
canInvite = state.canInvite,
onBackClick = navigator::exitRoomMemberList,
onInviteClick = navigator::openInviteMembers,
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
.fillMaxWidth()
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RoomMemberSearchBar(
query = state.searchQuery,
state = state.searchResults,
active = state.isSearchActive,
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onSelectUser = ::onSelectUser,
selectedSection = state.selectedSection,
modifier = Modifier.fillMaxWidth(),
var searchQuery by textFieldState(state.searchQuery)
SearchField(
value = searchQuery,
onValueChange = { newQuery ->
searchQuery = newQuery
state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery))
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = stringResource(CommonStrings.common_search_for_someone),
)
RoomMemberList(
roomMembers = state.roomMembers,
selectedSection = state.selectedSection,
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
showSections = state.moderationState.canBan,
onSelectUser = ::onSelectUser,
)
if (!state.isSearchActive) {
RoomMemberList(
roomMembers = state.roomMembers,
showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canBan,
selectedSection = state.selectedSection,
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser,
)
}
}
}
}
@ -124,25 +118,24 @@ fun RoomMemberListView(
@Composable
private fun RoomMemberList(
roomMembers: AsyncData<RoomMembers>,
showMembersCount: Boolean,
selectedSection: SelectedSection,
showSections: Boolean = true,
onSelectedSectionChange: (SelectedSection) -> Unit,
canDisplayBannedUsersControls: Boolean,
onSelectUser: (RoomMember) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
stickyHeader {
Column {
if (canDisplayBannedUsersControls) {
if (showSections) {
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),
.background(ElementTheme.colors.bgCanvasDefault)
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
for ((index, title) in segmentedButtonTitles.withIndex()) {
SegmentedButton(
@ -171,7 +164,6 @@ private fun RoomMemberList(
roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
selectedSection = selectedSection,
onSelectUser = onSelectUser,
showMembersCount = showMembersCount,
)
AsyncData.Uninitialized -> Unit
}
@ -182,27 +174,29 @@ private fun LazyListScope.memberItems(
roomMembers: RoomMembers,
selectedSection: SelectedSection,
onSelectUser: (RoomMember) -> Unit,
showMembersCount: Boolean,
) {
when (selectedSection) {
SelectedSection.MEMBERS -> {
if (roomMembers.invited.isNotEmpty()) {
roomMemberListSection(
headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) },
roomMemberListSectionHeader(
text = {
val memberCount = roomMembers.invited.count()
stringResource(id = R.string.screen_room_member_list_pending_header_title, memberCount)
},
)
roomMemberListSectionItems(
members = roomMembers.invited,
onMemberSelected = { onSelectUser(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)
}
roomMemberListSectionHeader(
text = {
val memberCount = roomMembers.joined.count()
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
},
)
roomMemberListSectionItems(
members = roomMembers.joined,
onMemberSelected = { onSelectUser(it) }
)
@ -210,8 +204,14 @@ private fun LazyListScope.memberItems(
}
SelectedSection.BANNED -> { // Banned users
if (roomMembers.banned.isNotEmpty()) {
roomMemberListSection(
headerText = null,
roomMemberListSectionHeader(
text = {
val memberCount = roomMembers.banned.count()
stringResource(id = R.string.screen_room_member_list_banned_header_title, memberCount)
},
isCritical = true,
)
roomMemberListSectionItems(
members = roomMembers.banned,
onMemberSelected = { onSelectUser(it) }
)
@ -219,13 +219,13 @@ private fun LazyListScope.memberItems(
item {
Box(
Modifier
.fillParentMaxSize()
.padding(horizontal = 16.dp)
.fillParentMaxSize()
.padding(horizontal = 16.dp)
) {
Text(
modifier = Modifier
.padding(bottom = 56.dp)
.align(Alignment.Center),
.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 +241,8 @@ private fun LazyListScope.failureItem(failure: Throwable) {
item {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp),
text = stringResource(id = CommonStrings.error_unknown) + "\n\n" + failure.localizedMessage,
color = ElementTheme.colors.textCriticalPrimary,
textAlign = TextAlign.Center,
@ -250,21 +250,25 @@ private fun LazyListScope.failureItem(failure: Throwable) {
}
}
private fun LazyListScope.roomMemberListSection(
headerText: @Composable (() -> String)?,
private fun LazyListScope.roomMemberListSectionHeader(
text: @Composable (() -> String),
modifier: Modifier = Modifier,
isCritical: Boolean = false,
) {
item {
Text(
modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = text(),
style = ElementTheme.typography.fontBodyLgMedium,
color = if (isCritical) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary,
)
}
}
private fun LazyListScope.roomMemberListSectionItems(
members: ImmutableList<RoomMemberWithIdentityState>?,
onMemberSelected: (RoomMember) -> Unit,
) {
headerText?.let {
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = it(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
items(members.orEmpty()) { matrixUser ->
RoomMemberListItem(
modifier = Modifier.fillMaxWidth(),
@ -353,44 +357,6 @@ private fun RoomMemberListTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberSearchBar(
query: String,
state: SearchBarResultState<AsyncData<RoomMembers>>,
active: Boolean,
placeHolderTitle: String,
onActiveChange: (Boolean) -> Unit,
onTextChange: (String) -> Unit,
onSelectUser: (RoomMember) -> Unit,
selectedSection: SelectedSection,
modifier: Modifier = Modifier,
) {
var queryFieldState by textFieldState(query)
SearchBar(
query = queryFieldState,
onQueryChange = { newQuery ->
queryFieldState = newQuery
onTextChange(newQuery)
},
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
resultState = state,
resultHandler = { results ->
RoomMemberList(
roomMembers = results,
showMembersCount = false,
onSelectUser = { onSelectUser(it) },
canDisplayBannedUsersControls = false,
selectedSection = selectedSection,
onSelectedSectionChange = {},
)
},
)
}
@PreviewsDayNight
@Composable
internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {

View file

@ -71,9 +71,10 @@
<string name="screen_room_details_topic_title">"Topic"</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_header_title">"%1$d Banned"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d person"</item>
<item quantity="other">"%1$d people"</item>
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d People"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Ban user"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Only remove member"</string>
@ -82,7 +83,8 @@
<string name="screen_room_member_list_manage_member_unban_title">"Unban user"</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_pending_header_title">"%1$d Invited"</string>
<string name="screen_room_member_list_pending_status">"Pending"</string>
<string name="screen_room_member_list_role_administrator">"Admin"</string>
<string name="screen_room_member_list_role_moderator">"Moderator"</string>
<string name="screen_room_member_list_role_owner">"Owner"</string>

View file

@ -198,6 +198,7 @@
"screen_room_details_.*",
"screen\\.room_details\\..*",
"screen_room_member_list_.*",
"screen\\.room_member_list\\..*",
"screen_room_notification_settings_.*",
"screen_notification_settings_edit_failed_updating_default_mode",
"screen_polls_history_title",