Merge pull request #5806 from element-hq/feature/fga/iterate_members

Change : improve room and space member list
This commit is contained in:
ganfra
2025-11-26 10:55:35 +01:00
committed by GitHub
51 changed files with 668 additions and 662 deletions

View File

@@ -1,39 +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.roomMembers
import kotlinx.coroutines.withContext
@Inject
class RoomMemberListDataSource(
private val room: BaseRoom,
private val coroutineDispatchers: CoroutineDispatchers,
) {
suspend fun search(query: String): List<RoomMember> = withContext(coroutineDispatchers.io) {
val roomMembersState = room.membersStateFlow.value
val activeRoomMembers = roomMembersState.roomMembers()
?.filter { it.membership.isActive() }
.orEmpty()
val filteredMembers = if (query.isBlank()) {
activeRoomMembers
} else {
activeRoomMembers.filter { member ->
member.userId.value.contains(query, ignoreCase = true) ||
member.displayName?.contains(query, ignoreCase = true).orFalse()
}
}
filteredMembers
}
}

View File

@@ -11,7 +11,7 @@ package io.element.android.features.roomdetails.impl.members
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

@@ -9,6 +9,8 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -21,6 +23,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
@@ -41,6 +44,7 @@ class RoomMemberListNode(
}
private val callback: Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
init {
lifecycle.subscribe(
@@ -64,7 +68,7 @@ class RoomMemberListNode(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val state by stateFlow.collectAsState()
RoomMemberListView(
state = state,
modifier = modifier,

View File

@@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,12 +19,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents.ShowActionsForUser
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
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@@ -40,7 +41,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
@@ -48,22 +48,15 @@ import kotlinx.coroutines.withContext
@Inject
class RoomMemberListPresenter(
private val room: JoinedRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService,
) : Presenter<RoomMemberListState> {
private var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
@Composable
override fun present(): RoomMemberListState {
var searchQuery by rememberSaveable { mutableStateOf("") }
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)
@@ -77,6 +70,10 @@ class RoomMemberListPresenter(
.launchIn(this)
}
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS) }
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
var filteredRoomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading()) }
// Update the room members when the screen is loaded
LaunchedEffect(Unit) {
room.updateMembers()
@@ -94,7 +91,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.
@@ -121,58 +118,38 @@ class RoomMemberListPresenter(
}
}
LaunchedEffect(membersState, searchQuery, isSearchActive) {
withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty() || !isSearchActive) {
SearchBarResultState.Initial()
} else {
val results = roomMemberListDataSource.search(searchQuery).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(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser()))
roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section
}
}
return RoomMemberListState(
val state = RoomMemberListState(
roomMembers = roomMembers,
filteredRoomMembers = filteredRoomMembers,
searchQuery = searchQuery,
searchResults = searchResults,
isSearchActive = isSearchActive,
canInvite = canInvite,
moderationState = roomModerationState,
selectedSection = selectedSection,
eventSink = ::handleEvent,
)
if (!state.showBannedSection && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
return state
}
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {

View File

@@ -10,26 +10,57 @@ 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>,
// Only used to know if we can show the banned section
private val roomMembers: AsyncData<RoomMembers>,
val filteredRoomMembers: AsyncData<RoomMembers>,
val searchQuery: String,
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
)
) {
val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
}
enum class SelectedSection {
MEMBERS,
BANNED
}
data class RoomMembers(
val invited: ImmutableList<RoomMemberWithIdentityState>,
val joined: 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 {
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

@@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
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.architecture.map
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.room.RoomMember
@@ -23,113 +23,75 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
override val values: Sequence<RoomMemberListState>
get() = sequenceOf(
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
banned = persistentListOf(),
)
)
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(
anAlice().withIdentity(identityState = IdentityState.Verified),
aBob().withIdentity(identityState = IdentityState.PinViolation),
aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
),
banned = persistentListOf(),
)
),
moderationState = aRoomMemberModerationState(canBan = true)
),
aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true),
aRoomMemberListState().copy(isSearchActive = false),
aRoomMemberListState().copy(isSearchActive = true),
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = SearchBarResultState.Results(
AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity()),
joined = persistentListOf(anAlice().withIdentity()),
banned = persistentListOf(),
)
)
),
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
roomMembers = AsyncData.Loading(),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = AsyncData.Failure(Exception("Error details")),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = aLoadedRoomMembers(),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = aLoadedRoomMembers(),
selectedSection = SelectedSection.BANNED,
moderationState = aRoomMemberModerationState(canBan = true),
),
aRoomMemberListState(
roomMembers = aLoadedRoomMembers(),
canInvite = true,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = aLoadedRoomMembers(),
searchQuery = "alice",
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = aLoadedRoomMembers(),
searchQuery = "something-with-no-results",
selectedSection = SelectedSection.MEMBERS,
),
)
}
internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState>
get() = sequenceOf(
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
moderationState = aRoomMemberModerationState(),
),
aRoomMemberListState(
roomMembers = AsyncData.Loading(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
moderationState = aRoomMemberModerationState(),
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(),
)
),
moderationState = aRoomMemberModerationState(),
)
)
}
private fun aLoadedRoomMembers() = AsyncData.Success(
RoomMembers(
invited = persistentListOf(
anInvitedVictor().withIdentity(),
anInvitedWalter().withIdentity(),
),
joined = persistentListOf(
anAlice().withIdentity(identityState = IdentityState.Verified),
aBob().withIdentity(identityState = IdentityState.PinViolation),
aCarol().withIdentity(),
aDavid().withIdentity(),
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
),
banned = persistentListOf(
aBannedMallory().withIdentity(),
aBannedSusie().withIdentity()
),
)
)
internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
selectedSection: SelectedSection = SelectedSection.MEMBERS,
searchQuery: String = "",
canInvite: Boolean = false,
eventSink: (RoomMemberListEvents) -> Unit = {},
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
searchResults = searchResults,
isSearchActive = false,
canInvite = false,
filteredRoomMembers = roomMembers.map { it.filter(searchQuery) },
searchQuery = searchQuery,
canInvite = canInvite,
moderationState = moderationState,
eventSink = {}
selectedSection = selectedSection,
eventSink = eventSink
)
fun aRoomMemberModerationState(
@@ -168,21 +130,30 @@ fun aRoomMember(
fun aRoomMemberList() = persistentListOf(
anAlice(),
aBob(),
aRoomMember(UserId("@carol:server.org"), "Carol"),
aRoomMember(UserId("@david:server.org"), "David"),
aRoomMember(UserId("@eve:server.org"), "Eve"),
aRoomMember(UserId("@justin:server.org"), "Justin"),
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
aRoomMember(UserId("@susie:server.org"), "Susie"),
aVictor(),
aWalter(),
aCarol(),
aDavid(),
anEve(),
anInvitedVictor(),
anInvitedWalter(),
aBannedSusie(),
aBannedMallory(),
)
fun anEve(): RoomMember = aRoomMember(UserId("@eve:server.org"), "Eve")
fun aDavid(): RoomMember = aRoomMember(UserId("@david:server.org"), "David")
fun aCarol(): RoomMember = aRoomMember(UserId("@carol:server.org"), "Carol")
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
fun anInvitedVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)
fun aWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
fun anInvitedWalter() = aRoomMember(UserId("@walter:server.org"), "Walter", membership = RoomMembershipState.INVITE)
fun aBannedSusie(): RoomMember = aRoomMember(UserId("@susie:server.org"), "Susie", membership = RoomMembershipState.BAN)
fun aBannedMallory(): RoomMember = aRoomMember(UserId("@mallory:server.org"), "Mallory", membership = RoomMembershipState.BAN)
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)

View File

@@ -9,14 +9,9 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -30,10 +25,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
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
@@ -46,15 +38,17 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
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.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview
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
@@ -68,17 +62,11 @@ 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(
state: RoomMemberListState,
navigator: RoomMemberListNavigator,
modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0,
) {
fun onSelectUser(roomMember: RoomMember) {
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
@@ -87,21 +75,13 @@ 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 ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canBan && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -109,45 +89,43 @@ fun RoomMemberListView(
.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 = 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(
roomMembersData = state.filteredRoomMembers,
selectedSection = state.selectedSection,
showBannedSection = state.showBannedSection,
searchQuery = state.searchQuery,
onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser,
)
if (!state.isSearchActive) {
RoomMemberList(
roomMembers = state.roomMembers,
showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canBan,
selectedSection = selectedSection,
onSelectedSectionChange = { selectedSection = it },
onSelectUser = ::onSelectUser,
)
}
}
}
}
@Composable
private fun RoomMemberList(
roomMembers: AsyncData<RoomMembers>,
showMembersCount: Boolean,
roomMembersData: AsyncData<RoomMembers>,
selectedSection: SelectedSection,
showBannedSection: Boolean,
searchQuery: String,
onSelectedSectionChange: (SelectedSection) -> Unit,
canDisplayBannedUsersControls: Boolean,
onSelectUser: (RoomMember) -> Unit,
) {
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
stickyHeader {
Column {
if (canDisplayBannedUsersControls) {
AnimatedVisibility(visible = showBannedSection) {
val segmentedButtonTitles = persistentListOf(
stringResource(id = R.string.screen_room_member_list_mode_members),
stringResource(id = R.string.screen_room_member_list_mode_banned),
@@ -169,24 +147,26 @@ private fun RoomMemberList(
}
}
}
AnimatedVisibility(
visible = roomMembers.isLoading(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
AnimatedVisibility(visible = roomMembersData.isLoading()) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
when (roomMembers) {
is AsyncData.Failure -> failureItem(roomMembers.error)
when (roomMembersData) {
is AsyncData.Failure -> failureItem(roomMembersData.error)
is AsyncData.Loading,
is AsyncData.Success -> memberItems(
roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
selectedSection = selectedSection,
onSelectUser = onSelectUser,
showMembersCount = showMembersCount,
)
is AsyncData.Success -> {
val roomMembers = roomMembersData.dataOrNull() ?: return@LazyColumn
if (roomMembers.isEmpty(selectedSection)) {
emptySearchItem(searchQuery)
} else {
memberItems(
roomMembers = roomMembers,
selectedSection = selectedSection,
onSelectUser = onSelectUser,
)
}
}
AsyncData.Uninitialized -> Unit
}
}
@@ -196,60 +176,47 @@ private fun LazyListScope.memberItems(
roomMembers: RoomMembers,
selectedSection: SelectedSection,
onSelectUser: (RoomMember) -> Unit,
showMembersCount: Boolean,
) {
when (selectedSection) {
SelectedSection.MEMBERS -> {
if (roomMembers.invited.isNotEmpty()) {
roomMemberListSection(
headerText = {
// TODO Use showMembersCount? iOS seems to always render the number of users, even when searching for users.
val invitedCount = roomMembers.invited.count()
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, count = invitedCount, invitedCount)
roomMemberListSectionHeader(
text = {
val memberCount = roomMembers.invited.count()
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, memberCount, 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) }
)
}
}
SelectedSection.BANNED -> { // Banned users
SelectedSection.BANNED -> {
if (roomMembers.banned.isNotEmpty()) {
roomMemberListSection(
headerText = null,
roomMemberListSectionHeader(
text = {
val memberCount = roomMembers.banned.count()
pluralStringResource(id = R.plurals.screen_room_member_list_banned_header_title, memberCount, memberCount)
},
isCritical = true,
)
roomMemberListSectionItems(
members = roomMembers.banned,
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,
)
}
}
}
}
}
@@ -268,21 +235,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(),
@@ -292,6 +263,22 @@ private fun LazyListScope.roomMemberListSection(
}
}
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
private fun RoomMemberListItem(
roomMemberWithIdentity: RoomMemberWithIdentityState,
@@ -371,40 +358,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,
) {
SearchBar(
query = query,
onQueryChange = onTextChange,
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 {
@@ -413,13 +366,3 @@ internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProv
navigator = object : RoomMemberListNavigator {},
)
}
@PreviewsDayNight
@Composable
internal fun RoomMemberListViewBannedPreview(@PreviewParameter(RoomMemberListStateBannedProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
initialSelectedSectionIndex = 1,
state = state,
navigator = object : RoomMemberListNavigator {},
)
}

View File

@@ -8,36 +8,27 @@
package io.element.android.features.roomdetails.impl.members
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.Presenter
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.BaseRoom
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.time.withTimeout
import kotlinx.coroutines.withTimeout
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@ExperimentalCoroutinesApi
class RoomMemberListPresenterTest {
@@ -45,176 +36,131 @@ class RoomMemberListPresenterTest {
val warmUpRule = WarmUpRule()
@Test
fun `member loading is done automatically on start, but is async`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
)
val presenter = createPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
fun `initial state is loading`() = runTest {
val presenter = createPresenter()
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.roomMembers.isLoading()).isTrue()
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
assertThat(initialState.isSearchActive).isFalse()
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
// Skip item while the new members state is processed
skipItems(1)
val loadedMembersState = awaitItem()
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
assertThat(loadedMembersState.roomMembers.dataOrNull()?.invited)
.isEqualTo(listOf(RoomMemberWithIdentityState(aVictor(), null), RoomMemberWithIdentityState(aWalter(), null)))
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
assertThat(initialState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
}
}
@Test
fun `member loading is done automatically when RoomInfo's activeMemberCount changes`() = runTest {
val reloadMembersMutex = Mutex()
val updateMembersLambda = lambdaRecorder<Unit> {
if (reloadMembersMutex.isLocked) {
reloadMembersMutex.unlock()
fun `hide banned section when there is no banned users`() = runTest {
val allRoomMembers = aRoomMemberList()
val noBannedMembers = allRoomMembers
.filterNot { it.membership == RoomMembershipState.BAN }
.toImmutableList()
val room = createFakeJoinedRoom()
.apply {
givenRoomMembersState(RoomMembersState.Ready(allRoomMembers))
}
}
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = updateMembersLambda,
canInviteResult = { Result.success(true) }
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
)
val presenter = createPresenter(joinedRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.roomMembers.isLoading()).isTrue()
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
// Skip item while the new members state is processed
skipItems(1)
val loadedMembersState = awaitItem()
assertThat(loadedMembersState.roomMembers.isLoading()).isFalse()
assertThat(loadedMembersState.roomMembers.dataOrNull()?.joined).isNotEmpty()
// Assert no events are emitted only with that change
expectNoEvents()
// This will only progress if the `Room.updateMembers()` function is called, triggered by the RoomInfo change
withTimeout(10.seconds) {
reloadMembersMutex.withLock {
launch { room.givenRoomInfo(aRoomInfo(activeMembersCount = 0L)) }
}
}
// Update the room members state as `Room.updateMembers()` would have done with the actual implementation
room.givenRoomMembersState(RoomMembersState.Ready(persistentListOf()))
// Wait for another update
skipItems(1)
// The members should be reloaded now
assertThat(awaitItem().roomMembers.dataOrNull()?.joined).isEmpty()
}
}
@Test
fun `open search`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
joinedRoom = room,
roomMemberModerationState = aRoomMemberModerationState(canBan = true),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
val loadedState = awaitItem()
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
assertThat(loadedState.showBannedSection).isTrue()
loadedState.eventSink(RoomMemberListEvents.ChangeSelectedSection(SelectedSection.BANNED))
val bannedSectionState = awaitItem()
assertThat(bannedSectionState.selectedSection).isEqualTo(SelectedSection.BANNED)
// Now update the room members to have no banned users
room.givenRoomMembersState(RoomMembersState.Ready(noBannedMembers))
skipItems(1)
val searchActiveState = awaitItem()
assertThat(searchActiveState.isSearchActive).isTrue()
val noBannedMembersState = awaitItem()
assertThat(noBannedMembersState.showBannedSection).isFalse()
skipItems(1)
val finalState = awaitItem()
assertThat(finalState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
}
}
@Test
fun `member loading is done automatically on start, but is async`() = runTest {
val room = createFakeJoinedRoom()
val presenter = createPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
assertThat(initialState.searchQuery).isEmpty()
room.givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
// Skip items while the new members state is processed
skipItems(2)
val loadedState = awaitItem()
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
assertThat(loadedRoomMembers.joined).isNotEmpty()
assertThat(loadedRoomMembers.banned).isNotEmpty()
assertThat(loadedRoomMembers.invited).isNotEmpty()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
}
}
@Test
fun `search for something which is not found`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val room = createFakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
}
val presenter = createPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
val loadedState = awaitItem()
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
val searchActiveState = awaitItem()
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
skipItems(1)
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
assertThat(loadedRoomMembers.joined).isNotEmpty()
assertThat(loadedRoomMembers.banned).isNotEmpty()
assertThat(loadedRoomMembers.invited).isNotEmpty()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
val searchQueryUpdatedState = awaitItem()
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
val searchSearchResultDelivered = awaitItem()
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
assertThat(emptyRoomMembers.joined).isEmpty()
assertThat(emptyRoomMembers.banned).isEmpty()
assertThat(emptyRoomMembers.invited).isEmpty()
assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isTrue()
assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue()
}
}
@Test
fun `search for something which is found`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val room = createFakeJoinedRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
}
val presenter = createPresenter(joinedRoom = room)
presenter.test {
skipItems(1)
val loadedState = awaitItem()
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
val searchActiveState = awaitItem()
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
skipItems(1)
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
assertThat(loadedRoomMembers.joined).isNotEmpty()
assertThat(loadedRoomMembers.banned).isNotEmpty()
assertThat(loadedRoomMembers.invited).isNotEmpty()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("alice"))
val searchQueryUpdatedState = awaitItem()
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("alice")
val searchSearchResultDelivered = awaitItem()
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().roomMember.displayName)
.isEqualTo("Alice")
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
assertThat(emptyRoomMembers.joined).isNotEmpty()
assertThat(emptyRoomMembers.banned).isEmpty()
assertThat(emptyRoomMembers.invited).isEmpty()
assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue()
}
}
@Test
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canInviteResult = { Result.success(true) },
updateMembersResult = { Result.success(Unit) }
)
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val presenter = createPresenter()
presenter.test {
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.canInvite).isTrue()
@@ -224,17 +170,11 @@ class RoomMemberListPresenterTest {
@Test
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canInviteResult = { Result.success(false) },
updateMembersResult = { Result.success(Unit) }
)
joinedRoom = createFakeJoinedRoom(
canInviteResult = { Result.success(false) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
presenter.test {
val loadedState = awaitItem()
assertThat(loadedState.canInvite).isFalse()
}
@@ -243,70 +183,54 @@ class RoomMemberListPresenterTest {
@Test
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
val presenter = createPresenter(
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canInviteResult = { Result.failure(RuntimeException("Eek")) },
updateMembersResult = { Result.success(Unit) }
)
joinedRoom = createFakeJoinedRoom(
canInviteResult = { Result.failure(RuntimeException("Eek")) },
)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
presenter.test {
val loadedState = awaitItem()
assertThat(loadedState.canInvite).isFalse()
}
}
@Test
fun `present - RoomMemberSelected will open the moderation options when target user is not banned`() = runTest {
val roomMemberModerationPresenter = Presenter {
aRoomMemberModerationState(canBan = true, canKick = true)
}
fun `present - RoomMemberSelected will open the moderation options`() = runTest {
val presenter = createPresenter(
roomMemberModerationPresenter = roomMemberModerationPresenter,
joinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) },
canInviteResult = { Result.success(true) }
)
)
roomMemberModerationState = aRoomMemberModerationState(canBan = true, canKick = true)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
skipItems(1)
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(anInvitedVictor()))
}
}
}
@ExperimentalCoroutinesApi
private fun TestScope.createDataSource(
room: BaseRoom = FakeBaseRoom().apply {
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
},
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
) = RoomMemberListDataSource(room, coroutineDispatchers)
private fun createFakeJoinedRoom(
updateMembersResult: () -> Unit = { },
canInviteResult: (UserId) -> Result<Boolean> = { Result.success(true) },
): FakeJoinedRoom {
return FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = updateMembersResult,
canInviteResult = canInviteResult,
).apply {
// Needed to avoid discarding the loaded members as a partial and invalid result
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
}
)
}
@ExperimentalCoroutinesApi
private fun TestScope.createPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
joinedRoom: JoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
updateMembersResult = { Result.success(Unit) }
)
),
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
joinedRoom: JoinedRoom = createFakeJoinedRoom(),
encryptedService: FakeEncryptionService = FakeEncryptionService(),
roomMemberModerationPresenter: Presenter<RoomMemberModerationState> = Presenter {
aRoomMemberModerationState()
},
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
) = RoomMemberListPresenter(
room = joinedRoom,
roomMemberListDataSource = roomMemberListDataSource,
coroutineDispatchers = coroutineDispatchers,
roomMembersModerationPresenter = roomMemberModerationPresenter,
roomMembersModerationPresenter = Presenter {
roomMemberModerationState
},
encryptionService = encryptedService,
)

View File

@@ -232,32 +232,34 @@ private fun RoomMemberActionsBottomSheet(
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
avatarType = AvatarType.User,
modifier = Modifier
.padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally)
.padding(bottom = 24.dp)
.align(Alignment.CenterHorizontally)
)
user.displayName?.let {
Text(
text = it,
style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
}
val bestName = user.getBestName()
Text(
text = user.userId.value,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
text = bestName,
style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
// Show user ID only if it's different from the display name
if (bestName != user.userId.value) {
Text(
text = user.userId.value,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
)
)
}
Spacer(modifier = Modifier.height(32.dp))
for (actionState in actions) {
@@ -330,8 +332,8 @@ internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMembe
ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.fillMaxWidth()
.heightIn(min = 64.dp)
) {
RoomMemberModerationView(
state = state,

View File

@@ -88,7 +88,7 @@ class LeaveSpacePresenter(
}
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
selectableSpaceRooms = leaveSpaceRooms.map {
it?.others.orEmpty().map { room ->
it.others.map { room ->
SelectableSpaceRoom(
spaceRoom = room.spaceRoom,
isLastAdmin = room.isLastAdmin,

View File

@@ -62,7 +62,7 @@ class LeaveSpacePresenterTest {
val state = awaitItem()
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(3)
skipItems(2)
val stateError = awaitItem()
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
// Retry
@@ -84,7 +84,7 @@ class LeaveSpacePresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(3)
skipItems(2)
val finalState = awaitItem()
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
assertThat(finalState.isLastAdmin).isTrue()
@@ -120,7 +120,7 @@ class LeaveSpacePresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
skipItems(3)
skipItems(2)
val finalState = awaitItem()
// The current state is not in the sub room list
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3)
@@ -154,7 +154,7 @@ class LeaveSpacePresenterTest {
)
)
presenter.test {
skipItems(4)
skipItems(3)
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.isLastAdmin).isFalse()
@@ -218,7 +218,7 @@ class LeaveSpacePresenterTest {
)
)
presenter.test {
skipItems(4)
skipItems(3)
val state = awaitItem()
state.eventSink(LeaveSpaceEvents.LeaveSpace)
val stateLeaving = awaitItem()

View File

@@ -163,14 +163,14 @@ suspend inline fun <T> runUpdatingState(
}
inline fun <T, R> AsyncData<T>.map(
transform: (T?) -> R,
transform: (T) -> R,
): AsyncData<R> {
return when (this) {
is AsyncData.Failure -> AsyncData.Failure(
error = error,
prevData = transform(prevData)
prevData = prevData?.let { transform(prevData) }
)
is AsyncData.Loading -> AsyncData.Loading(transform(prevData))
is AsyncData.Loading -> AsyncData.Loading(prevData?.let { transform(prevData) })
is AsyncData.Success -> AsyncData.Success(transform(data))
AsyncData.Uninitialized -> AsyncData.Uninitialized
}

View File

@@ -47,7 +47,7 @@ enum class AvatarSize(val dp: Dp) {
InviteSender(16.dp),
EditRoomDetails(70.dp),
RoomListManageUser(70.dp),
RoomListManageUser(96.dp),
NotificationsOptIn(32.dp),

View File

@@ -0,0 +1,224 @@
/*
* 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.libraries.designsystem.theme.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.ui.strings.CommonStrings
/**
* https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1985-3223
*/
@Composable
fun SearchField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.isEmpty(),
innerTextField = innerTextField,
onClear = { onValueChange("") },
)
}
}
@Composable
fun SearchField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val focusManager = LocalFocusManager.current
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier,
textStyle = textFieldStyle(),
singleLine = true,
interactionSource = interactionSource,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search,
),
keyboardActions = KeyboardActions(
onSearch = {
focusManager.clearFocus()
}
),
cursorBrush = SolidColor(ElementTheme.colors.textActionAccent),
) { innerTextField ->
DecorationBox(
isFocused = isFocused,
placeholder = placeholder,
isTextEmpty = value.text.isEmpty(),
innerTextField = innerTextField,
onClear = { TextFieldValue() }
)
}
}
@Composable
private fun DecorationBox(
isFocused: Boolean,
placeholder: String?,
isTextEmpty: Boolean,
onClear: () -> Unit,
innerTextField: @Composable () -> Unit,
) {
SearchFieldContainer(
isFocused = isFocused,
) {
Row(modifier = Modifier.padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
if (placeholder != null && isTextEmpty) {
Text(
text = placeholder,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
innerTextField()
}
Spacer(modifier = Modifier.width(16.dp))
val showClearIcon = isFocused && !isTextEmpty
IconButton(onClick = onClear, enabled = showClearIcon) {
if (showClearIcon) {
Icon(
modifier = Modifier.background(ElementTheme.colors.iconSecondary, CircleShape),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
tint = ElementTheme.colors.iconOnSolidPrimary,
)
} else {
Icon(
imageVector = CompoundIcons.Search(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
}
}
}
}
@Composable
private fun SearchFieldContainer(
isFocused: Boolean,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(99.dp),
border = BorderStroke(
width = 1.dp,
color = if (isFocused) {
ElementTheme.colors.borderInteractiveHovered
} else {
ElementTheme.colors.borderInteractiveSecondary
}
),
color = ElementTheme.colors.bgSubtleSecondary,
content = content
)
}
@Composable
private fun textFieldStyle(): TextStyle {
return ElementTheme.typography.fontBodyLgRegular.copy(
color = ElementTheme.colors.textPrimary
)
}
@Preview(group = PreviewGroup.Search, heightDp = 1000)
@Composable
internal fun SearchFieldsLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview(group = PreviewGroup.Search, heightDp = 1000)
@Composable
internal fun SearchFieldsDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
@ExcludeFromCoverage
private fun ContentToPreview() {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
SearchField(
onValueChange = {},
placeholder = "Search",
value = "",
)
SearchField(
onValueChange = {},
placeholder = "Search",
value = "Search term",
)
}
}

View File

@@ -116,7 +116,6 @@ class KonsistPreviewTest {
"ProgressDialogWithContentPreview",
"ProgressDialogWithTextAndContentPreview",
"ReadReceiptBottomSheetPreview",
"RoomMemberListViewBannedPreview",
"SasEmojisPreview",
"SecureBackupSetupViewChangePreview",
"SelectedUserCannotRemovePreview",