User search : show a loader when fetching results

This commit is contained in:
ganfra 2024-01-04 21:27:44 +01:00
parent c6d109c424
commit 690a165411
21 changed files with 135 additions and 81 deletions

View file

@ -35,6 +35,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
@ -49,6 +50,7 @@ import kotlinx.collections.immutable.ImmutableList
fun SearchUserBar( fun SearchUserBar(
query: String, query: String,
state: SearchBarResultState<ImmutableList<UserSearchResult>>, state: SearchBarResultState<ImmutableList<UserSearchResult>>,
isSearching: Boolean,
selectedUsers: ImmutableList<MatrixUser>, selectedUsers: ImmutableList<MatrixUser>,
active: Boolean, active: Boolean,
isMultiSelectionEnabled: Boolean, isMultiSelectionEnabled: Boolean,
@ -99,6 +101,11 @@ fun SearchUserBar(
) )
} }
}, },
contentSuffix = {
if (isSearching) {
AsyncLoading()
}
},
resultState = state, resultState = state,
resultHandler = { users -> resultHandler = { users ->
LazyColumn(state = columnState) { LazyColumn(state = columnState) {

View file

@ -48,6 +48,7 @@ fun UserListView(
state = state.searchResults, state = state.searchResults,
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
active = state.isSearchActive, active = state.isSearchActive,
isSearching = state.isFetchingSearchResults,
isMultiSelectionEnabled = state.isMultiSelectionEnabled, isMultiSelectionEnabled = state.isMultiSelectionEnabled,
showBackButton = showBackButton, showBackButton = showBackButton,
onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) },

View file

@ -34,6 +34,8 @@ import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class DefaultUserListPresenter @AssistedInject constructor( class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs, @Assisted val args: UserListPresenterArgs,
@ -57,18 +59,21 @@ class DefaultUserListPresenter @AssistedInject constructor(
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList()) val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember { var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.NotSearching()) mutableStateOf(SearchBarResultState.Empty())
} }
var isFetchingSearchResults by remember { mutableStateOf(false) }
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
searchResults = SearchBarResultState.NotSearching() searchResults = SearchBarResultState.Empty()
isFetchingSearchResults = false
userRepository.search(searchQuery).collect { userRepository.search(searchQuery).onEach { state ->
isFetchingSearchResults = state.isFetchingSearchResults
searchResults = when { searchResults = when {
it.isEmpty() -> SearchBarResultState.NoResults() state.results.isEmpty() && state.isFetchingSearchResults -> SearchBarResultState.Empty()
else -> SearchBarResultState.Results(it.toImmutableList()) state.results.isEmpty() && !state.isFetchingSearchResults -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Results(state.results.toImmutableList())
} }
} }.launchIn(this)
} }
return UserListState( return UserListState(
@ -76,6 +81,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
searchResults = searchResults, searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(), selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
isFetchingSearchResults = isFetchingSearchResults,
selectionMode = args.selectionMode, selectionMode = args.selectionMode,
eventSink = { event -> eventSink = { event ->
when (event) { when (event) {

View file

@ -24,6 +24,7 @@ import kotlinx.collections.immutable.ImmutableList
data class UserListState( data class UserListState(
val searchQuery: String, val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>, val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val isFetchingSearchResults: Boolean,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, val selectionMode: SelectionMode,

View file

@ -51,17 +51,19 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResults() searchResults = SearchBarResultState.NoResultsFound()
), ),
aUserListState().copy(isSearchActive = true, searchQuery = "someone", selectionMode = SelectionMode.Single),
) )
} }
fun aUserListState() = UserListState( fun aUserListState() = UserListState(
isSearchActive = false, isSearchActive = false,
searchQuery = "", searchQuery = "",
searchResults = SearchBarResultState.NotSearching(), searchResults = SearchBarResultState.Empty(),
selectedUsers = persistentListOf(), selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single, selectionMode = SelectionMode.Single,
isFetchingSearchResults = false,
eventSink = {} eventSink = {}
) )

View file

@ -55,7 +55,7 @@ class DefaultUserListPresenterTests {
assertThat(initialState.isMultiSelectionEnabled).isFalse() assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty() assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
} }
} }
@ -76,7 +76,7 @@ class DefaultUserListPresenterTests {
assertThat(initialState.isMultiSelectionEnabled).isTrue() assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty() assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
} }
} }
@ -131,7 +131,7 @@ class DefaultUserListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice") assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2) skipItems(2)
@ -170,13 +170,13 @@ class DefaultUserListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice")) initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice") assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2) skipItems(2)
// When the results list is empty, the state is set to NoResults // When the results list is empty, the state is set to NoResults
userRepository.emitResult(emptyList()) userRepository.emitResult(emptyList())
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java) assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }

View file

@ -37,6 +37,8 @@ import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@ -50,16 +52,22 @@ class RoomInviteMembersPresenter @Inject constructor(
override fun present(): RoomInviteMembersState { override fun present(): RoomInviteMembersState {
val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) } val roomMembers = remember { mutableStateOf<Async<ImmutableList<RoomMember>>>(Async.Loading()) }
val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) } val selectedUsers = remember { mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf()) }
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.NotSearching()) } val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Empty()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var isFetchingSearchResults = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
fetchMembers(roomMembers) fetchMembers(roomMembers)
} }
LaunchedEffect(searchQuery, roomMembers) { LaunchedEffect(searchQuery, roomMembers) {
performSearch(searchResults, roomMembers, selectedUsers, searchQuery) performSearch(
searchResults = searchResults,
roomMembers = roomMembers,
selectedUsers = selectedUsers,
isFetchingSearchResults = isFetchingSearchResults,
searchQuery = searchQuery
)
} }
return RoomInviteMembersState( return RoomInviteMembersState(
@ -68,6 +76,7 @@ class RoomInviteMembersPresenter @Inject constructor(
searchQuery = searchQuery, searchQuery = searchQuery,
isSearchActive = searchActive, isSearchActive = searchActive,
searchResults = searchResults.value, searchResults = searchResults.value,
isFetchingSearchResults = isFetchingSearchResults.value,
eventSink = { eventSink = {
when (it) { when (it) {
is RoomInviteMembersEvents.OnSearchActiveChanged -> { is RoomInviteMembersEvents.OnSearchActiveChanged -> {
@ -117,16 +126,19 @@ class RoomInviteMembersPresenter @Inject constructor(
searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>, searchResults: MutableState<SearchBarResultState<ImmutableList<InvitableUser>>>,
roomMembers: MutableState<Async<ImmutableList<RoomMember>>>, roomMembers: MutableState<Async<ImmutableList<RoomMember>>>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>, selectedUsers: MutableState<ImmutableList<MatrixUser>>,
isFetchingSearchResults: MutableState<Boolean>,
searchQuery: String, searchQuery: String,
) = withContext(coroutineDispatchers.io) { ) = withContext(coroutineDispatchers.io) {
searchResults.value = SearchBarResultState.NotSearching() searchResults.value = SearchBarResultState.Empty()
isFetchingSearchResults.value = false
val joinedMembers = roomMembers.value.dataOrNull().orEmpty() val joinedMembers = roomMembers.value.dataOrNull().orEmpty()
userRepository.search(searchQuery).collect { userRepository.search(searchQuery).onEach { state ->
isFetchingSearchResults.value = state.isFetchingSearchResults
searchResults.value = when { searchResults.value = when {
it.isEmpty() -> SearchBarResultState.NoResults() state.results.isEmpty() && state.isFetchingSearchResults -> SearchBarResultState.Empty()
else -> SearchBarResultState.Results(it.map { result -> state.results.isEmpty() && !state.isFetchingSearchResults -> SearchBarResultState.NoResultsFound()
else -> SearchBarResultState.Results(state.results.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN val isJoined = existingMembership == RoomMembershipState.JOIN
val isInvited = existingMembership == RoomMembershipState.INVITE val isInvited = existingMembership == RoomMembershipState.INVITE
@ -139,7 +151,7 @@ class RoomInviteMembersPresenter @Inject constructor(
) )
}.toImmutableList()) }.toImmutableList())
} }
} }.launchIn(this)
} }
private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) { private suspend fun fetchMembers(roomMembers: MutableState<Async<ImmutableList<RoomMember>>>) {

View file

@ -23,6 +23,7 @@ import kotlinx.collections.immutable.ImmutableList
data class RoomInviteMembersState( data class RoomInviteMembersState(
val canInvite: Boolean, val canInvite: Boolean,
val searchQuery: String, val searchQuery: String,
val isFetchingSearchResults: Boolean,
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>, val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,

View file

@ -32,7 +32,7 @@ internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInv
aRoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()), aRoomInviteMembersState(canInvite = true, selectedUsers = aMatrixUserList().toImmutableList()),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query"), aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query"),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()), aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", selectedUsers = aMatrixUserList().toImmutableList()),
aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResults()), aRoomInviteMembersState(isSearchActive = true, searchQuery = "some query", searchResults = SearchBarResultState.NoResultsFound()),
aRoomInviteMembersState( aRoomInviteMembersState(
isSearchActive = true, isSearchActive = true,
canInvite = true, canInvite = true,
@ -70,9 +70,10 @@ internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInv
private fun aRoomInviteMembersState( private fun aRoomInviteMembersState(
canInvite: Boolean = false, canInvite: Boolean = false,
searchQuery: String = "", searchQuery: String = "",
searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.NotSearching(), searchResults: SearchBarResultState<ImmutableList<InvitableUser>> = SearchBarResultState.Empty(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(), selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
isFetchingSearchResults: Boolean = false,
): RoomInviteMembersState { ): RoomInviteMembersState {
return RoomInviteMembersState( return RoomInviteMembersState(
canInvite = canInvite, canInvite = canInvite,
@ -80,6 +81,7 @@ private fun aRoomInviteMembersState(
searchResults = searchResults, searchResults = searchResults,
selectedUsers = selectedUsers, selectedUsers = selectedUsers,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
isFetchingSearchResults = isFetchingSearchResults,
eventSink = {}, eventSink = {},
) )
} }

View file

@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -86,6 +87,7 @@ fun RoomInviteMembersView(
RoomInviteMembersSearchBar( RoomInviteMembersSearchBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
query = state.searchQuery, query = state.searchQuery,
isSearching = state.isFetchingSearchResults,
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
state = state.searchResults, state = state.searchResults,
active = state.isSearchActive, active = state.isSearchActive,
@ -139,6 +141,7 @@ private fun RoomInviteMembersTopBar(
private fun RoomInviteMembersSearchBar( private fun RoomInviteMembersSearchBar(
query: String, query: String,
state: SearchBarResultState<ImmutableList<InvitableUser>>, state: SearchBarResultState<ImmutableList<InvitableUser>>,
isSearching: Boolean,
selectedUsers: ImmutableList<MatrixUser>, selectedUsers: ImmutableList<MatrixUser>,
active: Boolean, active: Boolean,
onActiveChanged: (Boolean) -> Unit, onActiveChanged: (Boolean) -> Unit,
@ -167,6 +170,11 @@ private fun RoomInviteMembersSearchBar(
}, },
showBackButton = false, showBackButton = false,
resultState = state, resultState = state,
contentSuffix = {
if (isSearching) {
AsyncLoading()
}
},
resultHandler = { results -> resultHandler = { results ->
Text( Text(
text = stringResource(id = CommonStrings.common_search_results), text = stringResource(id = CommonStrings.common_search_results),

View file

@ -47,7 +47,7 @@ class RoomMemberListPresenter @Inject constructor(
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) } var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.NotSearching()) mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.Empty())
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
@ -71,10 +71,10 @@ class RoomMemberListPresenter @Inject constructor(
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty()) { searchResults = if (searchQuery.isEmpty()) {
SearchBarResultState.NotSearching() SearchBarResultState.Empty()
} else { } else {
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership } val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
if (results.isEmpty()) SearchBarResultState.NoResults() if (results.isEmpty()) SearchBarResultState.NoResultsFound()
else SearchBarResultState.Results( else SearchBarResultState.Results(
RoomMembers( RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),

View file

@ -53,14 +53,14 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState().copy( aRoomMemberListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResults() searchResults = SearchBarResultState.NoResultsFound()
), ),
) )
} }
internal fun aRoomMemberListState( internal fun aRoomMemberListState(
roomMembers: Async<RoomMembers> = Async.Uninitialized, roomMembers: Async<RoomMembers> = Async.Uninitialized,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.NotSearching(), searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Empty(),
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
searchQuery = "", searchQuery = "",

View file

@ -63,7 +63,7 @@ internal class RoomInviteMembersPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.canInvite).isFalse() assertThat(initialState.canInvite).isFalse()
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery).isEmpty()
@ -115,7 +115,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1) skipItems(1)
val resultState = awaitItem() val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java) assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }

View file

@ -55,7 +55,7 @@ class RoomMemberListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java) assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
assertThat(initialState.searchQuery).isEmpty() assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
val loadedState = awaitItem() val loadedState = awaitItem()
assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java) assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java)
@ -92,7 +92,7 @@ class RoomMemberListPresenterTests {
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.NoResults::class.java) assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }

View file

@ -59,7 +59,7 @@ fun <T> SearchBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
showBackButton: Boolean = true, showBackButton: Boolean = true,
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(), resultState: SearchBarResultState<T> = SearchBarResultState.Empty(),
shape: Shape = SearchBarDefaults.inputFieldShape, shape: Shape = SearchBarDefaults.inputFieldShape,
tonalElevation: Dp = SearchBarDefaults.TonalElevation, tonalElevation: Dp = SearchBarDefaults.TonalElevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets, windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
@ -129,7 +129,7 @@ fun <T> SearchBar(
resultHandler(resultState.results) resultHandler(resultState.results)
} }
is SearchBarResultState.NoResults<T> -> { is SearchBarResultState.NoResultsFound<T> -> {
// No results found, show a message // No results found, show a message
Spacer(Modifier.size(80.dp)) Spacer(Modifier.size(80.dp))
@ -184,10 +184,10 @@ object ElementSearchBarDefaults {
@Immutable @Immutable
sealed interface SearchBarResultState<in T> { sealed interface SearchBarResultState<in T> {
/** No search results are available yet (e.g. because the user hasn't entered a search term). */ /** No search results are available yet (e.g. because the user hasn't entered a search term). */
class NotSearching<T> : SearchBarResultState<T> class Empty<T> : SearchBarResultState<T>
/** The search has completed, but no results were found. */ /** The search has completed, but no results were found. */
class NoResults<T> : SearchBarResultState<T> class NoResultsFound<T> : SearchBarResultState<T>
/** The search has completed, and some matching users were found. */ /** The search has completed, and some matching users were found. */
data class Results<T>(val results: T) : SearchBarResultState<T> data class Results<T>(val results: T) : SearchBarResultState<T>
@ -199,7 +199,7 @@ internal fun SearchBarInactivePreview() = ElementThemedPreview { ContentToPrevie
@Preview(group = PreviewGroup.Search) @Preview(group = PreviewGroup.Search)
@Composable @Composable
internal fun SearchBarActiveEmptyQueryPreview() = ElementThemedPreview { internal fun SearchBarActiveNoneQueryPreview() = ElementThemedPreview {
ContentToPreview( ContentToPreview(
query = "", query = "",
active = true, active = true,
@ -231,7 +231,7 @@ internal fun SearchBarActiveWithNoResultsPreview() = ElementThemedPreview {
ContentToPreview( ContentToPreview(
query = "search term", query = "search term",
active = true, active = true,
resultState = SearchBarResultState.NoResults(), resultState = SearchBarResultState.NoResultsFound<String>(),
) )
} }
@ -257,16 +257,15 @@ internal fun SearchBarActiveWithContentPreview() = ElementThemedPreview {
.background(color = Color.Blue) .background(color = Color.Blue)
.fillMaxWidth() .fillMaxWidth()
) )
},
resultHandler = {
Text(
text = "Results go here",
modifier = Modifier
.background(color = Color.Green)
.fillMaxWidth()
)
} }
) ) {
Text(
text = "Results go here",
modifier = Modifier
.background(color = Color.Green)
.fillMaxWidth()
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -275,7 +274,7 @@ private fun ContentToPreview(
query: String = "", query: String = "",
active: Boolean = false, active: Boolean = false,
showBackButton: Boolean = true, showBackButton: Boolean = true,
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(), resultState: SearchBarResultState<String> = SearchBarResultState.Empty(),
contentPrefix: @Composable ColumnScope.() -> Unit = {}, contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {}, contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(String) -> Unit = {}, resultHandler: @Composable ColumnScope.(String) -> Unit = {},

View file

@ -51,7 +51,7 @@ class RoomSelectPresenter @AssistedInject constructor(
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) } var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.NotSearching()) } var results: SearchBarResultState<ImmutableList<RoomSummaryDetails>> by remember { mutableStateOf(SearchBarResultState.Empty()) }
val summaries by client.roomListService.allRooms.summaries.collectAsState() val summaries by client.roomListService.allRooms.summaries.collectAsState()
@ -64,7 +64,7 @@ class RoomSelectPresenter @AssistedInject constructor(
results = if (filteredSummaries.isNotEmpty()) { results = if (filteredSummaries.isNotEmpty()) {
SearchBarResultState.Results(filteredSummaries) SearchBarResultState.Results(filteredSummaries)
} else { } else {
SearchBarResultState.NoResults() SearchBarResultState.NoResultsFound()
} }
} }

View file

@ -48,7 +48,7 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
} }
private fun aRoomSelectState( private fun aRoomSelectState(
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.NotSearching(), resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.Empty(),
query: String = "", query: String = "",
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(), selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),

View file

@ -45,11 +45,11 @@ class RoomSelectPresenterTests {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty() assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java) assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(initialState.isSearchActive).isFalse() assertThat(initialState.isSearchActive).isFalse()
// Search is run automatically // Search is run automatically
val searchState = awaitItem() val searchState = awaitItem()
assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) assertThat(searchState.resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }
@ -85,7 +85,7 @@ class RoomSelectPresenterTests {
initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained")) initialState.eventSink(RoomSelectEvents.UpdateQuery("string not contained"))
assertThat(awaitItem().query).isEqualTo("string not contained") assertThat(awaitItem().query).isEqualTo("string not contained")
assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResults::class.java) assertThat(awaitItem().resultState).isInstanceOf(SearchBarResultState.NoResultsFound::class.java)
} }
} }

View file

@ -20,5 +20,5 @@ import kotlinx.coroutines.flow.Flow
interface UserRepository { interface UserRepository {
suspend fun search(query: String): Flow<List<UserSearchResult>> fun search(query: String): Flow<UserSearchResultsState>
} }

View file

@ -22,3 +22,8 @@ data class UserSearchResult(
val matrixUser: MatrixUser, val matrixUser: MatrixUser,
val isUnresolved: Boolean = false, val isUnresolved: Boolean = false,
) )
data class UserSearchResultsState(
val results: List<UserSearchResult>,
val isFetchingSearchResults: Boolean
)

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource import io.element.android.libraries.usersearch.api.UserListDataSource
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.api.UserSearchResultsState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -36,36 +37,45 @@ class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource private val dataSource: UserListDataSource
) : UserRepository { ) : UserRepository {
override suspend fun search(query: String): Flow<List<UserSearchResult>> = flow { override fun search(query: String): Flow<UserSearchResultsState> = flow {
// If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results.
val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query)) val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query))
if (shouldQueryProfile) { val shouldFetchSearchResults = query.length >= MINIMUM_SEARCH_LENGTH
emit(listOf(UserSearchResult(MatrixUser(UserId(query))))) // If the search term is a MXID that's not ours, we'll show a 'fake' result for that user, then update it when we get search results.
val fakeSearchResult = if (shouldQueryProfile) {
UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true)
} else {
null
} }
if (shouldQueryProfile || shouldFetchSearchResults) {
if (query.length >= MINIMUM_SEARCH_LENGTH) { emit(UserSearchResultsState(isFetchingSearchResults = shouldFetchSearchResults, results = listOfNotNull(fakeSearchResult)))
// Debounce }
delay(DEBOUNCE_TIME_MILLIS) if (shouldFetchSearchResults) {
val results = fetchSearchResults(query, shouldQueryProfile)
val results = dataSource
.search(query, MAXIMUM_SEARCH_RESULTS)
.filter { !client.isMe(it.userId) }
.map { UserSearchResult(it) }
.toMutableList()
// If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly
if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) {
results.add(
0,
dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
}
emit(results) emit(results)
} }
} }
private suspend fun fetchSearchResults(query: String, shouldQueryProfile: Boolean): UserSearchResultsState {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource
.search(query, MAXIMUM_SEARCH_RESULTS)
.filter { !client.isMe(it.userId) }
.map { UserSearchResult(it) }
.toMutableList()
// If the query is another user's MXID and the result doesn't contain that user ID, query the profile information explicitly
if (shouldQueryProfile && results.none { it.matrixUser.userId.value == query }) {
results.add(
0,
dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
}
return UserSearchResultsState(results = results, isFetchingSearchResults = false)
}
companion object { companion object {
private const val DEBOUNCE_TIME_MILLIS = 250L private const val DEBOUNCE_TIME_MILLIS = 250L
private const val MINIMUM_SEARCH_LENGTH = 3 private const val MINIMUM_SEARCH_LENGTH = 3