Merge pull request #5806 from element-hq/feature/fga/iterate_members
Change : improve room and space member list
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +116,6 @@ class KonsistPreviewTest {
|
||||
"ProgressDialogWithContentPreview",
|
||||
"ProgressDialogWithTextAndContentPreview",
|
||||
"ReadReceiptBottomSheetPreview",
|
||||
"RoomMemberListViewBannedPreview",
|
||||
"SasEmojisPreview",
|
||||
"SecureBackupSetupViewChangePreview",
|
||||
"SelectedUserCannotRemovePreview",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user