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
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
||||||
sealed interface RoomMemberListEvents {
|
sealed interface RoomMemberListEvents {
|
||||||
|
data class ChangeSelectedSection(val section: SelectedSection) : RoomMemberListEvents
|
||||||
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
|
||||||
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
|
|
||||||
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
|
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
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.ModerationAction
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
|
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.architecture.callback
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
@@ -41,6 +44,7 @@ class RoomMemberListNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
private val stateFlow = launchMolecule { presenter.present() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
@@ -64,7 +68,7 @@ class RoomMemberListNode(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state by stateFlow.collectAsState()
|
||||||
RoomMemberListView(
|
RoomMemberListView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -18,12 +19,12 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
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.features.roommembermoderation.api.RoomMemberModerationState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
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.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -48,22 +48,15 @@ import kotlinx.coroutines.withContext
|
|||||||
@Inject
|
@Inject
|
||||||
class RoomMemberListPresenter(
|
class RoomMemberListPresenter(
|
||||||
private val room: JoinedRoom,
|
private val room: JoinedRoom,
|
||||||
private val roomMemberListDataSource: RoomMemberListDataSource,
|
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
|
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
) : Presenter<RoomMemberListState> {
|
) : Presenter<RoomMemberListState> {
|
||||||
private var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
|
|
||||||
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
|
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomMemberListState {
|
override fun present(): RoomMemberListState {
|
||||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
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 membersState by room.membersStateFlow.collectAsState()
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||||
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
|
val canInvite by room.canInviteAsState(syncUpdateFlow.value)
|
||||||
@@ -77,6 +70,10 @@ class RoomMemberListPresenter(
|
|||||||
.launchIn(this)
|
.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
|
// Update the room members when the screen is loaded
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
room.updateMembers()
|
room.updateMembers()
|
||||||
@@ -94,7 +91,7 @@ class RoomMemberListPresenter(
|
|||||||
}
|
}
|
||||||
withContext(coroutineDispatchers.io) {
|
withContext(coroutineDispatchers.io) {
|
||||||
val members = membersState.roomMembers().orEmpty().groupBy { it.membership }
|
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) {
|
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:
|
// 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.
|
// 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) {
|
LaunchedEffect(searchQuery, roomMembers) {
|
||||||
withContext(coroutineDispatchers.io) {
|
filteredRoomMembers = roomMembers.map { members ->
|
||||||
searchResults = if (searchQuery.isEmpty() || !isSearchActive) {
|
withContext(coroutineDispatchers.io) {
|
||||||
SearchBarResultState.Initial()
|
members.filter(searchQuery)
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: RoomMemberListEvents) {
|
fun handleEvent(event: RoomMemberListEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
|
||||||
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||||
is RoomMemberListEvents.RoomMemberSelected ->
|
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,
|
roomMembers = roomMembers,
|
||||||
|
filteredRoomMembers = filteredRoomMembers,
|
||||||
searchQuery = searchQuery,
|
searchQuery = searchQuery,
|
||||||
searchResults = searchResults,
|
|
||||||
isSearchActive = isSearchActive,
|
|
||||||
canInvite = canInvite,
|
canInvite = canInvite,
|
||||||
moderationState = roomModerationState,
|
moderationState = roomModerationState,
|
||||||
|
selectedSection = selectedSection,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
|
if (!state.showBannedSection && selectedSection == SelectedSection.BANNED) {
|
||||||
|
SideEffect {
|
||||||
|
selectedSection = SelectedSection.MEMBERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
private suspend fun RoomMember.withIdentityState(identityStates: ImmutableMap<UserId, IdentityState>): RoomMemberWithIdentityState {
|
||||||
|
|||||||
@@ -10,26 +10,57 @@ package io.element.android.features.roomdetails.impl.members
|
|||||||
|
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
data class RoomMemberListState(
|
data class RoomMemberListState(
|
||||||
val roomMembers: AsyncData<RoomMembers>,
|
// Only used to know if we can show the banned section
|
||||||
|
private val roomMembers: AsyncData<RoomMembers>,
|
||||||
|
val filteredRoomMembers: AsyncData<RoomMembers>,
|
||||||
val searchQuery: String,
|
val searchQuery: String,
|
||||||
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
|
|
||||||
val isSearchActive: Boolean,
|
|
||||||
val canInvite: Boolean,
|
val canInvite: Boolean,
|
||||||
|
val selectedSection: SelectedSection,
|
||||||
val moderationState: RoomMemberModerationState,
|
val moderationState: RoomMemberModerationState,
|
||||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||||
)
|
) {
|
||||||
|
val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SelectedSection {
|
||||||
|
MEMBERS,
|
||||||
|
BANNED
|
||||||
|
}
|
||||||
|
|
||||||
data class RoomMembers(
|
data class RoomMembers(
|
||||||
val invited: ImmutableList<RoomMemberWithIdentityState>,
|
val invited: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
val joined: ImmutableList<RoomMemberWithIdentityState>,
|
val joined: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
val banned: ImmutableList<RoomMemberWithIdentityState>,
|
val banned: ImmutableList<RoomMemberWithIdentityState>,
|
||||||
)
|
) {
|
||||||
|
fun isEmpty(section: SelectedSection): Boolean {
|
||||||
|
return when (section) {
|
||||||
|
SelectedSection.MEMBERS -> invited.isEmpty() && joined.isEmpty()
|
||||||
|
SelectedSection.BANNED -> banned.isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filter(query: String): RoomMembers {
|
||||||
|
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(
|
data class RoomMemberWithIdentityState(
|
||||||
val roomMember: RoomMember,
|
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.RoomMemberModerationEvents
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
@@ -23,113 +23,75 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
|||||||
override val values: Sequence<RoomMemberListState>
|
override val values: Sequence<RoomMemberListState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aRoomMemberListState(
|
aRoomMemberListState(
|
||||||
roomMembers = AsyncData.Success(
|
roomMembers = AsyncData.Loading(),
|
||||||
RoomMembers(
|
selectedSection = SelectedSection.MEMBERS,
|
||||||
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()
|
|
||||||
),
|
),
|
||||||
aRoomMemberListState(
|
aRoomMemberListState(
|
||||||
roomMembers = AsyncData.Failure(Exception("Error details")),
|
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> {
|
private fun aLoadedRoomMembers() = AsyncData.Success(
|
||||||
override val values: Sequence<RoomMemberListState>
|
RoomMembers(
|
||||||
get() = sequenceOf(
|
invited = persistentListOf(
|
||||||
aRoomMemberListState(
|
anInvitedVictor().withIdentity(),
|
||||||
roomMembers = AsyncData.Success(
|
anInvitedWalter().withIdentity(),
|
||||||
RoomMembers(
|
),
|
||||||
invited = persistentListOf(),
|
joined = persistentListOf(
|
||||||
joined = persistentListOf(),
|
anAlice().withIdentity(identityState = IdentityState.Verified),
|
||||||
banned = persistentListOf(
|
aBob().withIdentity(identityState = IdentityState.PinViolation),
|
||||||
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
|
aCarol().withIdentity(),
|
||||||
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
|
aDavid().withIdentity(),
|
||||||
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
|
anEve().withIdentity(identityState = IdentityState.VerificationViolation)
|
||||||
),
|
),
|
||||||
)
|
banned = persistentListOf(
|
||||||
),
|
aBannedMallory().withIdentity(),
|
||||||
moderationState = aRoomMemberModerationState(),
|
aBannedSusie().withIdentity()
|
||||||
),
|
),
|
||||||
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(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun aRoomMemberListState(
|
internal fun aRoomMemberListState(
|
||||||
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
|
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
|
||||||
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
|
|
||||||
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||||
|
selectedSection: SelectedSection = SelectedSection.MEMBERS,
|
||||||
|
searchQuery: String = "",
|
||||||
|
canInvite: Boolean = false,
|
||||||
|
eventSink: (RoomMemberListEvents) -> Unit = {},
|
||||||
) = RoomMemberListState(
|
) = RoomMemberListState(
|
||||||
roomMembers = roomMembers,
|
roomMembers = roomMembers,
|
||||||
searchQuery = "",
|
filteredRoomMembers = roomMembers.map { it.filter(searchQuery) },
|
||||||
searchResults = searchResults,
|
searchQuery = searchQuery,
|
||||||
isSearchActive = false,
|
canInvite = canInvite,
|
||||||
canInvite = false,
|
|
||||||
moderationState = moderationState,
|
moderationState = moderationState,
|
||||||
eventSink = {}
|
selectedSection = selectedSection,
|
||||||
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aRoomMemberModerationState(
|
fun aRoomMemberModerationState(
|
||||||
@@ -168,21 +130,30 @@ fun aRoomMember(
|
|||||||
fun aRoomMemberList() = persistentListOf(
|
fun aRoomMemberList() = persistentListOf(
|
||||||
anAlice(),
|
anAlice(),
|
||||||
aBob(),
|
aBob(),
|
||||||
aRoomMember(UserId("@carol:server.org"), "Carol"),
|
aCarol(),
|
||||||
aRoomMember(UserId("@david:server.org"), "David"),
|
aDavid(),
|
||||||
aRoomMember(UserId("@eve:server.org"), "Eve"),
|
anEve(),
|
||||||
aRoomMember(UserId("@justin:server.org"), "Justin"),
|
anInvitedVictor(),
|
||||||
aRoomMember(UserId("@mallory:server.org"), "Mallory"),
|
anInvitedWalter(),
|
||||||
aRoomMember(UserId("@susie:server.org"), "Susie"),
|
aBannedSusie(),
|
||||||
aVictor(),
|
aBannedMallory(),
|
||||||
aWalter(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.Admin)
|
||||||
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.Moderator)
|
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)
|
private fun RoomMember.withIdentity(identityState: IdentityState? = null) = RoomMemberWithIdentityState(this, identityState)
|
||||||
|
|||||||
@@ -9,14 +9,9 @@
|
|||||||
package io.element.android.features.roomdetails.impl.members
|
package io.element.android.features.roomdetails.impl.members
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
@@ -30,10 +25,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.roomdetails.impl.R
|
import io.element.android.features.roomdetails.impl.R
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
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.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
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.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
import io.element.android.libraries.designsystem.theme.components.SearchField
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
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.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
private enum class SelectedSection {
|
|
||||||
MEMBERS,
|
|
||||||
BANNED
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomMemberListView(
|
fun RoomMemberListView(
|
||||||
state: RoomMemberListState,
|
state: RoomMemberListState,
|
||||||
navigator: RoomMemberListNavigator,
|
navigator: RoomMemberListNavigator,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialSelectedSectionIndex: Int = 0,
|
|
||||||
) {
|
) {
|
||||||
fun onSelectUser(roomMember: RoomMember) {
|
fun onSelectUser(roomMember: RoomMember) {
|
||||||
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
|
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
|
||||||
@@ -87,21 +75,13 @@ fun RoomMemberListView(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
if (!state.isSearchActive) {
|
RoomMemberListTopBar(
|
||||||
RoomMemberListTopBar(
|
canInvite = state.canInvite,
|
||||||
canInvite = state.canInvite,
|
onBackClick = navigator::exitRoomMemberList,
|
||||||
onBackClick = navigator::exitRoomMemberList,
|
onInviteClick = navigator::openInviteMembers,
|
||||||
onInviteClick = navigator::openInviteMembers,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
|
|
||||||
if (!state.moderationState.canBan && selectedSection == SelectedSection.BANNED) {
|
|
||||||
SideEffect {
|
|
||||||
selectedSection = SelectedSection.MEMBERS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -109,45 +89,43 @@ fun RoomMemberListView(
|
|||||||
.consumeWindowInsets(padding),
|
.consumeWindowInsets(padding),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
RoomMemberSearchBar(
|
var searchQuery by textFieldState(state.searchQuery)
|
||||||
query = state.searchQuery,
|
SearchField(
|
||||||
state = state.searchResults,
|
value = searchQuery,
|
||||||
active = state.isSearchActive,
|
onValueChange = { newQuery ->
|
||||||
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
|
searchQuery = newQuery
|
||||||
onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
|
state.eventSink(RoomMemberListEvents.UpdateSearchQuery(newQuery))
|
||||||
onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
|
},
|
||||||
onSelectUser = ::onSelectUser,
|
modifier = Modifier
|
||||||
selectedSection = selectedSection,
|
.fillMaxWidth()
|
||||||
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
|
@Composable
|
||||||
private fun RoomMemberList(
|
private fun RoomMemberList(
|
||||||
roomMembers: AsyncData<RoomMembers>,
|
roomMembersData: AsyncData<RoomMembers>,
|
||||||
showMembersCount: Boolean,
|
|
||||||
selectedSection: SelectedSection,
|
selectedSection: SelectedSection,
|
||||||
|
showBannedSection: Boolean,
|
||||||
|
searchQuery: String,
|
||||||
onSelectedSectionChange: (SelectedSection) -> Unit,
|
onSelectedSectionChange: (SelectedSection) -> Unit,
|
||||||
canDisplayBannedUsersControls: Boolean,
|
|
||||||
onSelectUser: (RoomMember) -> Unit,
|
onSelectUser: (RoomMember) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) {
|
||||||
stickyHeader {
|
stickyHeader {
|
||||||
Column {
|
Column {
|
||||||
if (canDisplayBannedUsersControls) {
|
AnimatedVisibility(visible = showBannedSection) {
|
||||||
val segmentedButtonTitles = persistentListOf(
|
val segmentedButtonTitles = persistentListOf(
|
||||||
stringResource(id = R.string.screen_room_member_list_mode_members),
|
stringResource(id = R.string.screen_room_member_list_mode_members),
|
||||||
stringResource(id = R.string.screen_room_member_list_mode_banned),
|
stringResource(id = R.string.screen_room_member_list_mode_banned),
|
||||||
@@ -169,24 +147,26 @@ private fun RoomMemberList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(visible = roomMembersData.isLoading()) {
|
||||||
visible = roomMembers.isLoading(),
|
|
||||||
enter = fadeIn() + expandVertically(),
|
|
||||||
exit = fadeOut() + shrinkVertically(),
|
|
||||||
) {
|
|
||||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (roomMembers) {
|
when (roomMembersData) {
|
||||||
is AsyncData.Failure -> failureItem(roomMembers.error)
|
is AsyncData.Failure -> failureItem(roomMembersData.error)
|
||||||
is AsyncData.Loading,
|
is AsyncData.Loading,
|
||||||
is AsyncData.Success -> memberItems(
|
is AsyncData.Success -> {
|
||||||
roomMembers = roomMembers.dataOrNull() ?: return@LazyColumn,
|
val roomMembers = roomMembersData.dataOrNull() ?: return@LazyColumn
|
||||||
selectedSection = selectedSection,
|
if (roomMembers.isEmpty(selectedSection)) {
|
||||||
onSelectUser = onSelectUser,
|
emptySearchItem(searchQuery)
|
||||||
showMembersCount = showMembersCount,
|
} else {
|
||||||
)
|
memberItems(
|
||||||
|
roomMembers = roomMembers,
|
||||||
|
selectedSection = selectedSection,
|
||||||
|
onSelectUser = onSelectUser,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
AsyncData.Uninitialized -> Unit
|
AsyncData.Uninitialized -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,60 +176,47 @@ private fun LazyListScope.memberItems(
|
|||||||
roomMembers: RoomMembers,
|
roomMembers: RoomMembers,
|
||||||
selectedSection: SelectedSection,
|
selectedSection: SelectedSection,
|
||||||
onSelectUser: (RoomMember) -> Unit,
|
onSelectUser: (RoomMember) -> Unit,
|
||||||
showMembersCount: Boolean,
|
|
||||||
) {
|
) {
|
||||||
when (selectedSection) {
|
when (selectedSection) {
|
||||||
SelectedSection.MEMBERS -> {
|
SelectedSection.MEMBERS -> {
|
||||||
if (roomMembers.invited.isNotEmpty()) {
|
if (roomMembers.invited.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = {
|
text = {
|
||||||
// TODO Use showMembersCount? iOS seems to always render the number of users, even when searching for users.
|
val memberCount = roomMembers.invited.count()
|
||||||
val invitedCount = roomMembers.invited.count()
|
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, memberCount, memberCount)
|
||||||
pluralStringResource(id = R.plurals.screen_room_member_list_pending_header_title, count = invitedCount, invitedCount)
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
roomMemberListSectionItems(
|
||||||
members = roomMembers.invited,
|
members = roomMembers.invited,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (roomMembers.joined.isNotEmpty()) {
|
if (roomMembers.joined.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = {
|
text = {
|
||||||
if (showMembersCount) {
|
val memberCount = roomMembers.joined.count()
|
||||||
val memberCount = roomMembers.joined.count()
|
pluralStringResource(id = R.plurals.screen_room_member_list_header_title, count = memberCount, memberCount)
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
roomMemberListSectionItems(
|
||||||
members = roomMembers.joined,
|
members = roomMembers.joined,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SelectedSection.BANNED -> { // Banned users
|
SelectedSection.BANNED -> {
|
||||||
if (roomMembers.banned.isNotEmpty()) {
|
if (roomMembers.banned.isNotEmpty()) {
|
||||||
roomMemberListSection(
|
roomMemberListSectionHeader(
|
||||||
headerText = null,
|
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,
|
members = roomMembers.banned,
|
||||||
onMemberSelected = { onSelectUser(it) }
|
onMemberSelected = { onSelectUser(it) }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillParentMaxSize()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(bottom = 56.dp)
|
|
||||||
.align(Alignment.Center),
|
|
||||||
text = stringResource(id = R.string.screen_room_member_list_banned_empty),
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,21 +235,25 @@ private fun LazyListScope.failureItem(failure: Throwable) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun LazyListScope.roomMemberListSection(
|
private fun LazyListScope.roomMemberListSectionHeader(
|
||||||
headerText: @Composable (() -> String)?,
|
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>?,
|
members: ImmutableList<RoomMemberWithIdentityState>?,
|
||||||
onMemberSelected: (RoomMember) -> Unit,
|
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 ->
|
items(members.orEmpty()) { matrixUser ->
|
||||||
RoomMemberListItem(
|
RoomMemberListItem(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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
|
@Composable
|
||||||
private fun RoomMemberListItem(
|
private fun RoomMemberListItem(
|
||||||
roomMemberWithIdentity: RoomMemberWithIdentityState,
|
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
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
|
internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
|
||||||
@@ -413,13 +366,3 @@ internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProv
|
|||||||
navigator = object : RoomMemberListNavigator {},
|
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
|
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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.room.BaseRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
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.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.encryption.FakeEncryptionService
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
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.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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 io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
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.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.time.withTimeout
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class RoomMemberListPresenterTest {
|
class RoomMemberListPresenterTest {
|
||||||
@@ -45,176 +36,131 @@ class RoomMemberListPresenterTest {
|
|||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `member loading is done automatically on start, but is async`() = runTest {
|
fun `initial state is loading`() = runTest {
|
||||||
val room = FakeJoinedRoom(
|
val presenter = createPresenter()
|
||||||
baseRoom = FakeBaseRoom(
|
presenter.test {
|
||||||
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 {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.roomMembers.isLoading()).isTrue()
|
assertThat(initialState.filteredRoomMembers.isLoading()).isTrue()
|
||||||
assertThat(initialState.searchQuery).isEmpty()
|
assertThat(initialState.searchQuery).isEmpty()
|
||||||
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
assertThat(initialState.selectedSection).isEqualTo(SelectedSection.MEMBERS)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `member loading is done automatically when RoomInfo's activeMemberCount changes`() = runTest {
|
fun `hide banned section when there is no banned users`() = runTest {
|
||||||
val reloadMembersMutex = Mutex()
|
val allRoomMembers = aRoomMemberList()
|
||||||
val updateMembersLambda = lambdaRecorder<Unit> {
|
val noBannedMembers = allRoomMembers
|
||||||
if (reloadMembersMutex.isLocked) {
|
.filterNot { it.membership == RoomMembershipState.BAN }
|
||||||
reloadMembersMutex.unlock()
|
.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(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = room,
|
||||||
baseRoom = FakeBaseRoom(
|
roomMemberModerationState = aRoomMemberModerationState(canBan = true),
|
||||||
updateMembersResult = { Result.success(Unit) },
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
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)
|
skipItems(1)
|
||||||
val searchActiveState = awaitItem()
|
val noBannedMembersState = awaitItem()
|
||||||
assertThat(searchActiveState.isSearchActive).isTrue()
|
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
|
@Test
|
||||||
fun `search for something which is not found`() = runTest {
|
fun `search for something which is not found`() = runTest {
|
||||||
val presenter = createPresenter(
|
val room = createFakeJoinedRoom().apply {
|
||||||
joinedRoom = FakeJoinedRoom(
|
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
baseRoom = FakeBaseRoom(
|
}
|
||||||
updateMembersResult = { Result.success(Unit) },
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
canInviteResult = { Result.success(true) }
|
presenter.test {
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
|
||||||
val searchActiveState = awaitItem()
|
assertThat(loadedRoomMembers.joined).isNotEmpty()
|
||||||
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
assertThat(loadedRoomMembers.banned).isNotEmpty()
|
||||||
skipItems(1)
|
assertThat(loadedRoomMembers.invited).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("something"))
|
||||||
val searchQueryUpdatedState = awaitItem()
|
val searchQueryUpdatedState = awaitItem()
|
||||||
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
|
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("something")
|
||||||
val searchSearchResultDelivered = awaitItem()
|
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
|
@Test
|
||||||
fun `search for something which is found`() = runTest {
|
fun `search for something which is found`() = runTest {
|
||||||
val presenter = createPresenter(
|
val room = createFakeJoinedRoom().apply {
|
||||||
joinedRoom = FakeJoinedRoom(
|
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
||||||
baseRoom = FakeBaseRoom(
|
}
|
||||||
updateMembersResult = { Result.success(Unit) },
|
val presenter = createPresenter(joinedRoom = room)
|
||||||
canInviteResult = { Result.success(true) }
|
presenter.test {
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
loadedState.eventSink(RoomMemberListEvents.OnSearchActiveChanged(true))
|
val loadedRoomMembers = loadedState.filteredRoomMembers.dataOrNull()!!
|
||||||
val searchActiveState = awaitItem()
|
assertThat(loadedRoomMembers.joined).isNotEmpty()
|
||||||
searchActiveState.eventSink(RoomMemberListEvents.UpdateSearchQuery("Alice"))
|
assertThat(loadedRoomMembers.banned).isNotEmpty()
|
||||||
skipItems(1)
|
assertThat(loadedRoomMembers.invited).isNotEmpty()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(loadedRoomMembers.isEmpty(SelectedSection.BANNED)).isFalse()
|
||||||
|
loadedState.eventSink(RoomMemberListEvents.UpdateSearchQuery("alice"))
|
||||||
val searchQueryUpdatedState = awaitItem()
|
val searchQueryUpdatedState = awaitItem()
|
||||||
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("Alice")
|
assertThat(searchQueryUpdatedState.searchQuery).isEqualTo("alice")
|
||||||
val searchSearchResultDelivered = awaitItem()
|
val searchSearchResultDelivered = awaitItem()
|
||||||
assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
val emptyRoomMembers = searchSearchResultDelivered.filteredRoomMembers.dataOrNull()!!
|
||||||
assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.dataOrNull()!!.joined.first().roomMember.displayName)
|
assertThat(emptyRoomMembers.joined).isNotEmpty()
|
||||||
.isEqualTo("Alice")
|
assertThat(emptyRoomMembers.banned).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.invited).isEmpty()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.MEMBERS)).isFalse()
|
||||||
|
assertThat(emptyRoomMembers.isEmpty(SelectedSection.BANNED)).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
fun `present - asynchronously sets canInvite when user has correct power level`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter()
|
||||||
joinedRoom = FakeJoinedRoom(
|
presenter.test {
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
canInviteResult = { Result.success(true) },
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isTrue()
|
assertThat(loadedState.canInvite).isTrue()
|
||||||
@@ -224,17 +170,11 @@ class RoomMemberListPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = createFakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
canInviteResult = { Result.success(false) },
|
||||||
canInviteResult = { Result.success(false) },
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isFalse()
|
assertThat(loadedState.canInvite).isFalse()
|
||||||
}
|
}
|
||||||
@@ -243,70 +183,54 @@ class RoomMemberListPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
fun `present - asynchronously sets canInvite when power level check fails`() = runTest {
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
joinedRoom = FakeJoinedRoom(
|
joinedRoom = createFakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(
|
canInviteResult = { Result.failure(RuntimeException("Eek")) },
|
||||||
canInviteResult = { Result.failure(RuntimeException("Eek")) },
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
|
||||||
val loadedState = awaitItem()
|
val loadedState = awaitItem()
|
||||||
assertThat(loadedState.canInvite).isFalse()
|
assertThat(loadedState.canInvite).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - RoomMemberSelected will open the moderation options when target user is not banned`() = runTest {
|
fun `present - RoomMemberSelected will open the moderation options`() = runTest {
|
||||||
val roomMemberModerationPresenter = Presenter {
|
|
||||||
aRoomMemberModerationState(canBan = true, canKick = true)
|
|
||||||
}
|
|
||||||
val presenter = createPresenter(
|
val presenter = createPresenter(
|
||||||
roomMemberModerationPresenter = roomMemberModerationPresenter,
|
roomMemberModerationState = aRoomMemberModerationState(canBan = true, canKick = true)
|
||||||
joinedRoom = FakeJoinedRoom(
|
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
updateMembersResult = { Result.success(Unit) },
|
|
||||||
canInviteResult = { Result.success(true) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(aVictor()))
|
awaitItem().eventSink(RoomMemberListEvents.RoomMemberSelected(anInvitedVictor()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
private fun createFakeJoinedRoom(
|
||||||
private fun TestScope.createDataSource(
|
updateMembersResult: () -> Unit = { },
|
||||||
room: BaseRoom = FakeBaseRoom().apply {
|
canInviteResult: (UserId) -> Result<Boolean> = { Result.success(true) },
|
||||||
givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList()))
|
): FakeJoinedRoom {
|
||||||
},
|
return FakeJoinedRoom(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers()
|
baseRoom = FakeBaseRoom(
|
||||||
) = RoomMemberListDataSource(room, coroutineDispatchers)
|
updateMembersResult = updateMembersResult,
|
||||||
|
canInviteResult = canInviteResult,
|
||||||
|
).apply {
|
||||||
|
// Needed to avoid discarding the loaded members as a partial and invalid result
|
||||||
|
givenRoomInfo(aRoomInfo(joinedMembersCount = 2))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
private fun TestScope.createPresenter(
|
private fun TestScope.createPresenter(
|
||||||
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||||
joinedRoom: JoinedRoom = FakeJoinedRoom(
|
joinedRoom: JoinedRoom = createFakeJoinedRoom(),
|
||||||
baseRoom = FakeBaseRoom(
|
|
||||||
updateMembersResult = { Result.success(Unit) }
|
|
||||||
)
|
|
||||||
),
|
|
||||||
roomMemberListDataSource: RoomMemberListDataSource = createDataSource(coroutineDispatchers = coroutineDispatchers),
|
|
||||||
encryptedService: FakeEncryptionService = FakeEncryptionService(),
|
encryptedService: FakeEncryptionService = FakeEncryptionService(),
|
||||||
roomMemberModerationPresenter: Presenter<RoomMemberModerationState> = Presenter {
|
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||||
aRoomMemberModerationState()
|
|
||||||
},
|
|
||||||
) = RoomMemberListPresenter(
|
) = RoomMemberListPresenter(
|
||||||
room = joinedRoom,
|
room = joinedRoom,
|
||||||
roomMemberListDataSource = roomMemberListDataSource,
|
|
||||||
coroutineDispatchers = coroutineDispatchers,
|
coroutineDispatchers = coroutineDispatchers,
|
||||||
roomMembersModerationPresenter = roomMemberModerationPresenter,
|
roomMembersModerationPresenter = Presenter {
|
||||||
|
roomMemberModerationState
|
||||||
|
},
|
||||||
encryptionService = encryptedService,
|
encryptionService = encryptedService,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -232,32 +232,34 @@ private fun RoomMemberActionsBottomSheet(
|
|||||||
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
|
avatarData = user.getAvatarData(size = AvatarSize.RoomListManageUser),
|
||||||
avatarType = AvatarType.User,
|
avatarType = AvatarType.User,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 28.dp)
|
.padding(bottom = 24.dp)
|
||||||
.align(Alignment.CenterHorizontally)
|
.align(Alignment.CenterHorizontally)
|
||||||
)
|
)
|
||||||
user.displayName?.let {
|
val bestName = user.getBestName()
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = user.userId.value,
|
text = bestName,
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontHeadingLgBold,
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier
|
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)
|
.padding(horizontal = 16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
for (actionState in actions) {
|
for (actionState in actions) {
|
||||||
@@ -330,8 +332,8 @@ internal fun RoomMemberModerationViewPreview(@PreviewParameter(InternalRoomMembe
|
|||||||
ElementPreview {
|
ElementPreview {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = 64.dp)
|
.heightIn(min = 64.dp)
|
||||||
) {
|
) {
|
||||||
RoomMemberModerationView(
|
RoomMemberModerationView(
|
||||||
state = state,
|
state = state,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class LeaveSpacePresenter(
|
|||||||
}
|
}
|
||||||
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
|
LaunchedEffect(selectedRoomIds, leaveSpaceRooms) {
|
||||||
selectableSpaceRooms = leaveSpaceRooms.map {
|
selectableSpaceRooms = leaveSpaceRooms.map {
|
||||||
it?.others.orEmpty().map { room ->
|
it.others.map { room ->
|
||||||
SelectableSpaceRoom(
|
SelectableSpaceRoom(
|
||||||
spaceRoom = room.spaceRoom,
|
spaceRoom = room.spaceRoom,
|
||||||
isLastAdmin = room.isLastAdmin,
|
isLastAdmin = room.isLastAdmin,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class LeaveSpacePresenterTest {
|
|||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
|
assertThat(state.selectableSpaceRooms.isLoading()).isTrue()
|
||||||
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
|
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val stateError = awaitItem()
|
val stateError = awaitItem()
|
||||||
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
|
assertThat(stateError.selectableSpaceRooms.isFailure()).isTrue()
|
||||||
// Retry
|
// Retry
|
||||||
@@ -84,7 +84,7 @@ class LeaveSpacePresenterTest {
|
|||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
|
assertThat(finalState.spaceName).isEqualTo(A_SPACE_NAME)
|
||||||
assertThat(finalState.isLastAdmin).isTrue()
|
assertThat(finalState.isLastAdmin).isTrue()
|
||||||
@@ -120,7 +120,7 @@ class LeaveSpacePresenterTest {
|
|||||||
presenter.test {
|
presenter.test {
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
skipItems(3)
|
skipItems(2)
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
// The current state is not in the sub room list
|
// 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)
|
assertThat(finalState.selectableSpaceRooms.dataOrNull()!!.map { it.spaceRoom.roomId }).containsExactly(A_ROOM_ID, A_ROOM_ID_3)
|
||||||
@@ -154,7 +154,7 @@ class LeaveSpacePresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(4)
|
skipItems(3)
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
assertThat(state.spaceName).isNull()
|
assertThat(state.spaceName).isNull()
|
||||||
assertThat(state.isLastAdmin).isFalse()
|
assertThat(state.isLastAdmin).isFalse()
|
||||||
@@ -218,7 +218,7 @@ class LeaveSpacePresenterTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
skipItems(4)
|
skipItems(3)
|
||||||
val state = awaitItem()
|
val state = awaitItem()
|
||||||
state.eventSink(LeaveSpaceEvents.LeaveSpace)
|
state.eventSink(LeaveSpaceEvents.LeaveSpace)
|
||||||
val stateLeaving = awaitItem()
|
val stateLeaving = awaitItem()
|
||||||
|
|||||||
@@ -163,14 +163,14 @@ suspend inline fun <T> runUpdatingState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
inline fun <T, R> AsyncData<T>.map(
|
inline fun <T, R> AsyncData<T>.map(
|
||||||
transform: (T?) -> R,
|
transform: (T) -> R,
|
||||||
): AsyncData<R> {
|
): AsyncData<R> {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is AsyncData.Failure -> AsyncData.Failure(
|
is AsyncData.Failure -> AsyncData.Failure(
|
||||||
error = error,
|
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))
|
is AsyncData.Success -> AsyncData.Success(transform(data))
|
||||||
AsyncData.Uninitialized -> AsyncData.Uninitialized
|
AsyncData.Uninitialized -> AsyncData.Uninitialized
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ enum class AvatarSize(val dp: Dp) {
|
|||||||
InviteSender(16.dp),
|
InviteSender(16.dp),
|
||||||
|
|
||||||
EditRoomDetails(70.dp),
|
EditRoomDetails(70.dp),
|
||||||
RoomListManageUser(70.dp),
|
RoomListManageUser(96.dp),
|
||||||
|
|
||||||
NotificationsOptIn(32.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",
|
"ProgressDialogWithContentPreview",
|
||||||
"ProgressDialogWithTextAndContentPreview",
|
"ProgressDialogWithTextAndContentPreview",
|
||||||
"ReadReceiptBottomSheetPreview",
|
"ReadReceiptBottomSheetPreview",
|
||||||
"RoomMemberListViewBannedPreview",
|
|
||||||
"SasEmojisPreview",
|
"SasEmojisPreview",
|
||||||
"SecureBackupSetupViewChangePreview",
|
"SecureBackupSetupViewChangePreview",
|
||||||
"SelectedUserCannotRemovePreview",
|
"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