Search for users to start a new DM. (#376)

Search for users to start a new DM.

Hooks up the create room UI to the matrix client to get
search results. Searches are debounced for 500ms and
only executed when 3 or more characters are entered.

Wrap the result state so we can distinguish between
"no results because we haven't searched yet" and
"no results because the API returned nothing", and
add a "No results found" message in the UI for the
latter case.

Closes #95
This commit is contained in:
Chris Smith
2023-05-03 14:26:31 +01:00
committed by GitHub
parent c196174a95
commit 63168d4656
48 changed files with 432 additions and 71 deletions

1
changelog.d/95.feature Normal file
View File

@@ -0,0 +1 @@
[Create and join rooms] Search for users to start a DM

View File

@@ -426,6 +426,7 @@ Rageshake can be very useful to get logs from a release version of the applicati
- When this is possible, prefer using `sealed interface` instead of `sealed class`;
- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI
will detect this String and will warn the user about it. (TODO Not supported yet!)
- Very occasionally the gradle cache misbehaves and causes problems with Dagger. Try building with `--no-build-cache` if Dagger isn't behaving how you expect.
## Happy coding!

View File

@@ -17,17 +17,37 @@
package io.element.android.features.createroom.impl
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
import io.element.android.libraries.matrix.ui.model.MatrixUser
import javax.inject.Inject
// TODO this is empty as we currently don't have an endpoint to perform user search
class AllMatrixUsersDataSource @Inject constructor() : UserListDataSource {
class AllMatrixUsersDataSource @Inject constructor(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
return emptyList()
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
return res.getOrNull()?.results?.map(::toMatrixUser).orEmpty()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
// TODO hook up to matrix client
return null
}
private fun toMatrixUser(matrixUserProfile: MatrixUserProfile) = MatrixUser(
id = matrixUserProfile.userId,
username = matrixUserProfile.displayName,
avatarData = AvatarData(
id = matrixUserProfile.userId.value,
name = matrixUserProfile.displayName,
url = matrixUserProfile.avatarUrl,
)
)
companion object {
private const val MAX_SEARCH_RESULTS = 5L
}
}

View File

@@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
get() = sequenceOf(
aUserListState(),
aUserListState().copy(
searchResults = aMatrixUserList().toImmutableList(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(
searchResults = aMatrixUserList().toImmutableList(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,

View File

@@ -21,11 +21,11 @@ import dagger.Binds
import dagger.Module
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope
import javax.inject.Named
@Module
@ContributesTo(AppScope::class)
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
@Binds

View File

@@ -46,7 +46,11 @@ class CreateRoomRootPresenter @Inject constructor(
private val presenter by lazy {
presenterFactory.create(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
minimumSearchLength = 3,
searchDebouncePeriodMillis = 500,
),
userListDataSource,
userListDataStore,
)

View File

@@ -17,6 +17,7 @@
package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -31,7 +32,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
searchResults = UserSearchResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
@@ -42,7 +43,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
searchResults = UserSearchResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.createroom.impl
import com.google.common.truth.Truth
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class AllMatrixUsersDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
limited = false
)
)
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(
MatrixUser(
id = A_USER_ID,
username = A_USER_NAME,
avatarData = AvatarData(id = A_USER_ID.value, name = A_USER_NAME, url = AN_AVATAR_URL)
),
MatrixUser(
id = A_USER_ID_2,
username = A_USER_NAME,
avatarData = AvatarData(id = A_USER_ID_2.value, name = A_USER_NAME, url = AN_AVATAR_URL)
)
)
}
@Test
fun `search - returns empty list on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).isEmpty()
}
private fun aMatrixUserProfile(
userId: UserId = A_USER_ID,
displayName: String = A_USER_NAME,
avatarUrl: String = AN_AVATAR_URL
) = MatrixUserProfile(userId, displayName, avatarUrl)
}

View File

@@ -17,6 +17,7 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -33,7 +34,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
}
internal fun aRoomMemberListState(
searchResults: ImmutableList<MatrixUser> = persistentListOf(),
searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
) =
RoomMemberListState(

View File

@@ -26,15 +26,14 @@ import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.internal.toImmutableList
import org.junit.Test
@@ -72,7 +71,7 @@ class RoomMemberListPresenterTests {
val initialState = awaitItem()
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
Truth.assertThat(initialState.userListState.searchResults).isEmpty()
Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
val loadedState = awaitItem()

View File

@@ -15,6 +15,7 @@
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
}
android {
@@ -27,4 +28,5 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
ksp(libs.showkase.processor)
}

View File

@@ -18,6 +18,8 @@ package io.element.android.features.userlist.api
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
val minimumSearchLength: Int = 1,
val searchDebouncePeriodMillis: Long = 0,
)
enum class SelectionMode {

View File

@@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: ImmutableList<MatrixUser>,
val searchResults: UserSearchResultState,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
@@ -29,3 +29,14 @@ data class UserListState(
) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
}
sealed interface UserSearchResultState {
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
object NotSearching : UserSearchResultState
/** The search has completed, but no results were found. */
object NoResults : UserSearchResultState
/** The search has completed, and some matching users were found. */
data class Results(val results: ImmutableList<MatrixUser>) : UserSearchResultState
}

View File

@@ -37,22 +37,27 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = aMatrixUserList().toImmutableList(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = aMatrixUserList().toImmutableList(),
)
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = UserSearchResultState.NoResults
),
)
}
fun aUserListState() = UserListState(
isSearchActive = false,
searchQuery = "",
searchResults = persistentListOf(),
searchResults = UserSearchResultState.NotSearching,
selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single,
eventSink = {}

View File

@@ -17,14 +17,17 @@
package io.element.android.features.userlist.api.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -32,7 +35,9 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
@@ -46,7 +51,7 @@ import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
results: ImmutableList<MatrixUser>,
state: UserSearchResultState,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
@@ -91,6 +96,7 @@ fun SearchUserBar(
}
}
}
!active -> {
{
Icon(
@@ -100,6 +106,7 @@ fun SearchUserBar(
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
@@ -113,31 +120,43 @@ fun SearchUserBar(
)
}
LazyColumn {
if (isMultiSelectionEnabled) {
items(results) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
if (state is UserSearchResultState.Results) {
LazyColumn {
if (isMultiSelectionEnabled) {
items(state.results) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
}
)
}
} else {
items(results) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
)
}
} else {
items(state.results) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
} else if (state is UserSearchResultState.NoResults) {
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(R.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
},
)

View File

@@ -44,7 +44,7 @@ fun UserListView(
SearchUserBar(
modifier = Modifier.fillMaxWidth(),
query = state.searchQuery,
results = state.searchResults,
state = state.searchResults,
selectedUsers = state.selectedUsers,
active = state.isSearchActive,
isMultiSelectionEnabled = state.isMultiSelectionEnabled,

View File

@@ -18,7 +18,6 @@ package io.element.android.features.userlist.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -35,13 +34,14 @@ import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@@ -62,45 +62,47 @@ class DefaultUserListPresenter @AssistedInject constructor(
@Composable
override fun present(): UserListState {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers = userListDataStore.selectedUsers().collectAsState(emptyList())
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
mutableStateOf(persistentListOf())
}
fun handleEvents(event: UserListEvents) {
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
}
var searchResults: UserSearchResultState by remember {
mutableStateOf(UserSearchResultState.NotSearching)
}
LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
persistentListOf(MatrixUser(UserId(searchQuery)))
searchResults = if (MatrixPatterns.isUserId(searchQuery)) {
UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(searchQuery))))
} else {
persistentListOf()
UserSearchResultState.NotSearching
}
// Debounce
delay(args.searchDebouncePeriodMillis)
// Perform the search asynchronously
if (searchQuery.isNotEmpty()) {
searchResults.value = performSearch(searchQuery)
if (searchQuery.length >= args.minimumSearchLength) {
searchResults = performSearch(searchQuery)
}
}
return UserListState(
searchQuery = searchQuery,
searchResults = searchResults.value,
selectedUsers = selectedUsers.value.toImmutableList(),
searchResults = searchResults,
selectedUsers = selectedUsers.toImmutableList(),
isSearchActive = isSearchActive,
selectionMode = args.selectionMode,
eventSink = ::handleEvents,
eventSink = { event ->
when (event) {
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
}
},
)
}
private suspend fun performSearch(query: String): ImmutableList<MatrixUser> {
private suspend fun performSearch(query: String): UserSearchResultState {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = userListDataSource.search(query).toMutableList()
if (isMatrixId && results.none { it.id.value == query }) {
@@ -108,6 +110,6 @@ class DefaultUserListPresenter @AssistedInject constructor(
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return results.toImmutableList()
return if (results.isEmpty()) UserSearchResultState.NoResults else UserSearchResultState.Results(results.toImmutableList())
}
}

View File

@@ -25,12 +25,14 @@ import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.api.UserSearchResultState
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.mockk.coJustRun
import io.mockk.mockkConstructor
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -55,7 +57,7 @@ class DefaultUserListPresenterTests {
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
}
}
@@ -74,7 +76,7 @@ class DefaultUserListPresenterTests {
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
}
}
@@ -96,18 +98,42 @@ class DefaultUserListPresenterTests {
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(matrixIdQuery)))))
val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEmpty()
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NoResults)
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `present - searches when minimum length exceeded`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single, minimumSearchLength = 3),
userListDataSource,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// When the search term is too short, nothing happens
initialState.eventSink(UserListEvents.UpdateSearchQuery("al"))
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
// When it reaches the minimum length, a search is performed asynchronously
userListDataSource.givenSearchResult(listOf(aMatrixUser()))
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(aMatrixUser())))
}
}
@Test
fun `present - select a user`() = runTest {
mockkConstructor(LazyListState::class)

View File

@@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import java.io.Closeable
@@ -58,4 +59,6 @@ interface MatrixClient : Closeable {
fun onSlidingSyncUpdate()
fun roomMembershipObserver(): RoomMembershipObserver
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.usersearch
data class MatrixSearchUserResults(
val results: List<MatrixUserProfile>,
val limited: Boolean,
)

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.usersearch
import io.element.android.libraries.matrix.api.core.UserId
data class MatrixUserProfile(
val userId: UserId,
val displayName: String?,
val avatarUrl: String?
)

View File

@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
@@ -362,6 +364,13 @@ class RustMatrixClient constructor(
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(dispatchers.io) {
runCatching {
UserSearchResultMapper.map(client.searchUsers(searchTerm, limit.toULong()))
}
}
private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.usersearch
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
import org.matrix.rustcomponents.sdk.SearchUsersResults
import org.matrix.rustcomponents.sdk.UserProfile
object UserSearchResultMapper {
fun map(result: SearchUsersResults): MatrixSearchUserResults {
return MatrixSearchUserResults(
results = result.results.map(::mapUserProfile),
limited = result.limited,
)
}
private fun mapUserProfile(userProfile: UserProfile): MatrixUserProfile {
return MatrixUserProfile(
userId = UserId(userProfile.userId),
displayName = userProfile.displayName,
avatarUrl = userProfile.avatarUrl,
)
}
}

View File

@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
@@ -55,6 +56,7 @@ class FakeMatrixClient(
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
private var logoutFailure: Throwable? = null
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
override fun getRoom(roomId: RoomId): MatrixRoom? {
return getRoomResults[roomId]
@@ -126,6 +128,10 @@ class FakeMatrixClient(
return RoomMembershipObserver()
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
}
// Mocks
fun givenLogoutError(failure: Throwable?) {
@@ -159,4 +165,8 @@ class FakeMatrixClient(
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) {
getRoomResults[roomId] = result
}
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {
searchUserResults[searchTerm] = result
}
}