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

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

View file

@ -51,7 +51,7 @@ class RoomSelectPresenter @AssistedInject constructor(
var selectedRooms by remember { mutableStateOf(persistentListOf<RoomSummaryDetails>()) }
var query by remember { mutableStateOf("") }
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()
@ -64,7 +64,7 @@ class RoomSelectPresenter @AssistedInject constructor(
results = if (filteredSummaries.isNotEmpty()) {
SearchBarResultState.Results(filteredSummaries)
} else {
SearchBarResultState.NoResults()
SearchBarResultState.NoResultsFound()
}
}

View file

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

View file

@ -45,11 +45,11 @@ class RoomSelectPresenterTests {
}.test {
val initialState = awaitItem()
assertThat(initialState.selectedRooms).isEmpty()
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(initialState.resultState).isInstanceOf(SearchBarResultState.Empty::class.java)
assertThat(initialState.isSearchActive).isFalse()
// Search is run automatically
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"))
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 {
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 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.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.api.UserSearchResultsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -36,36 +37,45 @@ class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource
) : UserRepository {
override suspend fun search(query: String): Flow<List<UserSearchResult>> = 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.
override fun search(query: String): Flow<UserSearchResultsState> = flow {
val shouldQueryProfile = MatrixPatterns.isUserId(query) && !client.isMe(UserId(query))
if (shouldQueryProfile) {
emit(listOf(UserSearchResult(MatrixUser(UserId(query)))))
val shouldFetchSearchResults = query.length >= MINIMUM_SEARCH_LENGTH
// 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 (query.length >= MINIMUM_SEARCH_LENGTH) {
// 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))
}
if (shouldQueryProfile || shouldFetchSearchResults) {
emit(UserSearchResultsState(isFetchingSearchResults = shouldFetchSearchResults, results = listOfNotNull(fakeSearchResult)))
}
if (shouldFetchSearchResults) {
val results = fetchSearchResults(query, shouldQueryProfile)
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 {
private const val DEBOUNCE_TIME_MILLIS = 250L
private const val MINIMUM_SEARCH_LENGTH = 3