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

Change : improve room and space member list
This commit is contained in:
ganfra 2025-11-26 10:55:35 +01:00 committed by GitHub
commit 78d5850fe6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 668 additions and 662 deletions

View file

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

View file

@ -11,7 +11,7 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember 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
} }

View file

@ -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,

View file

@ -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) {
filteredRoomMembers = roomMembers.map { members ->
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty() || !isSearchActive) { members.filter(searchQuery)
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)
}
)
}
} }
} }
} }
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 {

View file

@ -10,26 +10,57 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.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,

View file

@ -12,7 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.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>
get() = sequenceOf(
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers( RoomMembers(
invited = persistentListOf(), invited = persistentListOf(
joined = 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( banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(), aBannedMallory().withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(), aBannedSusie().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(),
)
)
}
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)

View file

@ -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),
) )
if (!state.isSearchActive) {
RoomMemberList( RoomMemberList(
roomMembers = state.roomMembers, roomMembersData = state.filteredRoomMembers,
showMembersCount = true, selectedSection = state.selectedSection,
canDisplayBannedUsersControls = state.moderationState.canBan, showBannedSection = state.showBannedSection,
selectedSection = selectedSection, searchQuery = state.searchQuery,
onSelectedSectionChange = { selectedSection = it }, onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser, 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
if (roomMembers.isEmpty(selectedSection)) {
emptySearchItem(searchQuery)
} else {
memberItems(
roomMembers = roomMembers,
selectedSection = selectedSection, selectedSection = selectedSection,
onSelectUser = onSelectUser, onSelectUser = onSelectUser,
showMembersCount = showMembersCount,
) )
}
}
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),
members: ImmutableList<RoomMemberWithIdentityState>?, modifier: Modifier = Modifier,
onMemberSelected: (RoomMember) -> Unit, isCritical: Boolean = false,
) { ) {
headerText?.let {
item { item {
Text( Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
text = it(), text = text(),
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textSecondary, color = if (isCritical) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary,
) )
} }
} }
private fun LazyListScope.roomMemberListSectionItems(
members: ImmutableList<RoomMemberWithIdentityState>?,
onMemberSelected: (RoomMember) -> Unit,
) {
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 {},
)
}

View file

@ -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) }
) )
) presenter.test {
)
moleculeFlow(RecompositionMode.Immediate) {
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) }
) )
) )
) presenter.test {
moleculeFlow(RecompositionMode.Immediate) {
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) }
) )
) )
) presenter.test {
moleculeFlow(RecompositionMode.Immediate) {
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) }
) )
) presenter.test {
)
moleculeFlow(RecompositionMode.Immediate) {
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,
) )

View file

@ -232,12 +232,12 @@ 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(
text = it, text = bestName,
style = ElementTheme.typography.fontHeadingLgBold, style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -246,10 +246,11 @@ private fun RoomMemberActionsBottomSheet(
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp) .padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth() .fillMaxWidth()
) )
} // Show user ID only if it's different from the display name
if (bestName != user.userId.value) {
Text( Text(
text = user.userId.value, text = user.userId.value,
style = ElementTheme.typography.fontBodyLgRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -258,6 +259,7 @@ private fun RoomMemberActionsBottomSheet(
.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) {

View file

@ -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,

View file

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

View file

@ -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
} }

View file

@ -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),

View file

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

View file

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

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5070188463df330ea8f7969228f436dba0db5dd9eb9280f5b4484296176bf3e9
size 11503

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb
size 11741

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5070188463df330ea8f7969228f436dba0db5dd9eb9280f5b4484296176bf3e9
size 11503

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07dc1c12e55e3eb3ce6ee01582535641a08d62d4b059bdd581af6c58063ca70c
size 10849

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c
size 11067

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07dc1c12e55e3eb3ce6ee01582535641a08d62d4b059bdd581af6c58063ca70c
size 10849

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:08172934ef864d57cb335b738dea8ad6f96ee2df4710c4f9d7e5adc3d110ad67 oid sha256:367bc0df8b1839af2b434de55ea3815af4a8c112cc96e98ee76c3b9a4949fe52
size 44215 size 12985

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ad1fce13a5034b6db192a0735317b6cf5f10536ac05ccf1cc4996ad029ce9513 oid sha256:1700a0f9e6f9115015849c900d7faa27f70fa29752bde7666eeb36cf4eb1a6da
size 49951 size 19463

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb oid sha256:c8b5970490e96a693655df832f9c4bf83dd55a8b06da27b48881d9f61b2dfd9c
size 11741 size 55110

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cad1aae139861f1ba83d3b03a6d78916c2ced5f19e6e99b935b4a49c1b5d12bb oid sha256:bae192229d384047dcf5fb6744267a9e448d7f0a4f933a4984a0d1c3b9d07da2
size 12686 size 30789

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4f544e2018c0cd221e6bba338e61af4f306809d10d37bd5b20c33e0fa67cbabb oid sha256:6b7049a6865a0ea4529cc43d16a27b9107e26d6583f429affe8327840b84ac2a
size 11741 size 56050

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fd50f1d35f872ee93bd6b2aff1feaffcc2314b11fae60302351f738bd86d735c oid sha256:0b4a208d39a5f6040d17da5290a8f2f81cd917aa02c8e38b092fc79497e1e563
size 7630 size 18394

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8c0c23f8cb90609cc1f6107f4d34efbc044de92286cb237487993953f5497e5e oid sha256:acfaec83e840845137f9c07d1325e24f106bc67ccdbf6c548e77efe1d490596b
size 6521 size 29428

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f509f0c857c9d2c0a23657d213ea49961bb900b652edd116c4dcf29853c7776
size 24336

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58d4c3e636f71ac1408189c3c14da3b0dd1e700a1be02a02f373adbcb5604b48
size 10976

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cba49da736f96c2dd8ca3822e796930f4bd6ff3497b72f394d034147178906ce
size 18239

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bd62c76e687e342f46b792958ddd3c36d44f5c603e4ddbe79f4fd2a8e41b9750 oid sha256:7b01cfde0e59d64a9e6d576e22e2ac9eeab5ab1a99cb0f9f54d1c8cec80ac4bd
size 44198 size 12154

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cc7dd1459c04997170247296b34b9bd7953418361c2d72840d43aa159000a806 oid sha256:2cb19c23879eb9698729561fb1ef79c1e65a36eab0f24681e4cf72dfdde45d04
size 49655 size 18281

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c oid sha256:889734242ce96bfc206ae1421ab9c1cbccbcb11f99889445ea27833cbd057a0a
size 11067 size 55231

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:34198a1ba6c0f6385eb8732d36622ee0d6f865c72085d95d378d82ed7afe9486 oid sha256:dc20039c1c5d456f318f22e524360d411d4a4980b6a2a9312c0707c0fe865340
size 11936 size 29867

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1fe0b9379ed8bd9a3c19c7cff5eb6085657e1453e4f8d6ebe88a3338e738236c oid sha256:5bb86c6fea38d0e798cffa5fee0610a32048aeb1d3427cd9b5b749dfacee0e55
size 11067 size 56063

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:11c23632578610a78a6700f9d103ff6b4b3da03887cfb1918b10f2c6052e6820 oid sha256:a6d55832abbcffc4d84b7139fbfb19d4c96a5ebcc9233ff139c5aae6b9f406d9
size 7549 size 17831

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b838359a59cdc357f0ef80d701f6093ea45875c7915ba438901748f43ce9106b oid sha256:3802b661c6fc656f621ba3c368eeb93f004561b01926faa572c4ba5ee66d07eb
size 6316 size 28474

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd6d96abbeda8ab8dd7804929ed205cd2c1eb8bb04d83d00a343387e93bc693b
size 24297

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ced65352d0aee02e92f19fc890a8fa92efabfb8255a9a58e36dc539e2d4d6009
size 10664

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9f910e14188c705d993201bdebab2f818badc71272195aa4cce31728315beea
size 17207

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:54e526b8ab19ec121349dfc61f751b4d301c61ec47f1c1ec1c02d120b1f4faaf oid sha256:f333d59f48ec9ff036e72c727c3bfae32dbb919c35b854906fca153bfc1c84e4
size 17064 size 17375

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1be3ac8e92243baeceeb7349ed767e4c39ebfc4880b253e7f2e46ed9fc75da01 oid sha256:dd83d242f9099de1ae0c84b07cb773e08dde1ae535ee2fd2bcb002e2e0e22132
size 20222 size 20397

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4e8c820a8c4966526e7851b4f3152ccaf9072bbf8ebf21737d35d1c87325bf6f oid sha256:0f3d63a5c888eb1b951be3c46c4c51f7c622d7e7838c35b3de388ac1cd20789d
size 22524 size 22793

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:983e163f59ef03f5a7c30cf7073002105a663cec64f8a8d4fc89fa0991bfd730 oid sha256:e5ce9706fbc30f9d99c35c9d1f145c7f933295fe3eb62063c640b303526649db
size 22646 size 22915

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6db6ff988fd128f537764e5a3fcaa94e67db3057fc7030698adabe70f962c1b4 oid sha256:06aad1478e9096e5215bd3116a7bef0e98037383c835de73f57d46a9cfa86137
size 15991 size 16415

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e4868e2ce56debe8b175715417190db0037d59d204a28b5c0bc0b0c81a5d91a9 oid sha256:71cd9c85c33cc222836ec26f39e7209a954728d5beebadf53a784bd5a0813709
size 18925 size 19390

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a59481d4391dd8d0cb749ff63d3bb34846b450a6713ade11a6dbbe0461bb3650 oid sha256:66b527092681543f600725e508213f59524b5bdf3f3ceff2bbc47d43cc9d66e8
size 21257 size 21685

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:381fbeb4b57f044fb785b65d04b041c4ca5efa89138742b8480a7540ed57b4f0 oid sha256:47c1592948df21f0f3d69dead320f50e47ebf8da3bb86bc8302ad392d5813c57
size 21335 size 21765

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39e0775e5c569a95653ab922084c4056635bec2056441771af3c8a3739a84901
size 8143

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a97489a6af869148d1daadb5948f26d00e888ecf0373e6ec45eb8a5ed8b12173
size 8383