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()