Refactor search related functionality (#436)

Refactor search related functionality

This is a prelude to adding the feature of inviting users to
a room, getting everything in the right place and reusable.

What this does:

## User search refactor

Moves the (global) user search logic (dealing with MXIDs,
minimum lengths, debounces) into a `UserRepository`.

This now sits in a `usersearch` library, which will be
used by the create room flow and the new invite flow.

## SearchBar logic pull-up

Every place we use SearchBar, we're doing the same things
to style placeholders, show back/cancel buttons, etc.

We also have a results type that is duplicated for
basically every feature that uses the search bar.

I've pushed all this common functionality into the
SearchBar itself. This makes the component a bit less
general purpose, but saves a lot of repetition.

## Remove the userlist feature

Almost all the functionality of the userlist feature
is now exclusively used by the create room feature.
Room details uses its own version because the
requirements are different.

Components useful elsewhere (SelectedUsers and
SelectedUser) have gone to matrixui, everything else
has gone to createroom.

## Other bits and pieces

I've fixed everywhere that uses Scaffold to correctly
consume the WindowInsets if the contentPadding is
applied to the contents (which it universally is).
This was a change in the last version of Material3
(I guess previously Scaffold handled the consumption
for us). This fixes weird gaps above search bars.

Added overloads for the MatrixUserRow and
CheckedMatrixUserRow that take the name/subtitle/avatar
separately, so the invites list can pass arbitrary
text like "User has already been invited".

The `blockuser` package was for some reason not
under `impl` but alongside it, I've bumped it into
the right place.
This commit is contained in:
Chris Smith 2023-05-22 10:06:54 +01:00 committed by GitHub
parent 1885ea01eb
commit 848f1cb988
137 changed files with 1002 additions and 675 deletions

View file

@ -45,10 +45,10 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.features.userlist.api)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.mediaupload.api)
implementation(libs.coil.compose)
implementation(projects.libraries.usersearch.impl)
api(projects.features.createroom.api)
testImplementation(libs.test.junit)
@ -59,10 +59,9 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.impl)
testImplementation(projects.features.userlist.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)

View file

@ -19,7 +19,7 @@ package io.element.android.features.createroom.impl
import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.di.CreateRoomScope
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow

View file

@ -18,18 +18,17 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.runtime.Composable
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource
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.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource,
private val userRepository: UserRepository,
private val dataStore: CreateRoomDataStore,
) : Presenter<UserListState> {
@ -37,10 +36,8 @@ class AddPeoplePresenter @Inject constructor(
userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE
),
userListDataSource,
userRepository,
dataStore.selectedUserListDataStore,
)
}

View file

@ -17,11 +17,11 @@
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.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toImmutableList
@ -30,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
get() = sequenceOf(
aUserListState(),
aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
aUserListState().copy(
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,

View file

@ -17,6 +17,8 @@
package io.element.android.features.createroom.impl.addpeople
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -30,8 +32,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.components.UserListView
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -41,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddPeopleView(
state: UserListState,
@ -49,6 +52,7 @@ fun AddPeopleView(
onNextPressed: () -> Unit = {},
) {
Scaffold(
modifier = modifier,
topBar = {
if (!state.isSearchActive) {
AddPeopleViewTopBar(
@ -60,12 +64,14 @@ fun AddPeopleView(
}
) { padding ->
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(padding),
.padding(padding)
.consumeWindowInsets(padding),
) {
UserListView(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth(),
state = state,
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api.components
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api.components
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable

View file

@ -0,0 +1,95 @@
/*
* 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.components
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
SearchBar(
query = query,
onQueryChange = onTextChanged,
active = active,
onActiveChange = onActiveChanged,
modifier = modifier,
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
)
}
},
resultState = state,
resultHandler = { users ->
LazyColumn {
if (isMultiSelectionEnabled) {
items(users) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(matrixUser)
}
}
)
}
} else {
items(users) { matrixUser ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
)
}
}
}
},
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api.components
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -24,12 +24,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.UserListEvents
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.UserListStateProvider
import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@Composable
fun UserListView(

View file

@ -18,7 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.persistentListOf

View file

@ -20,8 +20,10 @@ import android.net.Uri
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectableGroup
@ -48,7 +50,6 @@ import io.element.android.features.createroom.impl.components.Avatar
import io.element.android.features.createroom.impl.components.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView
import io.element.android.features.userlist.api.components.SelectedUsersList
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
@ -60,10 +61,11 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable
fun ConfigureRoomView(
state: ConfigureRoomState,
@ -104,7 +106,9 @@ fun ConfigureRoomView(
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {

View file

@ -21,25 +21,24 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.SelectionMode
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.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource,
private val userRepository: UserRepository,
private val userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> {
@ -48,10 +47,8 @@ class CreateRoomRootPresenter @Inject constructor(
presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE,
),
userListDataSource,
userRepository,
userListDataStore,
)
}

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId

View file

@ -17,9 +17,10 @@
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.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
@ -32,7 +33,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
@ -43,7 +44,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.userId.value,
searchResults = UserSearchResultState.Results(persistentListOf(it)),
searchResults = SearchBarResultState.Results(persistentListOf(it)),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)

View file

@ -20,7 +20,9 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -32,7 +34,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -40,7 +41,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.createroom.impl.R
import io.element.android.features.userlist.api.components.UserListView
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -55,6 +56,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CreateRoomRootView(
state: CreateRoomRootState,
@ -78,10 +80,11 @@ fun CreateRoomRootView(
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues),
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val context = LocalContext.current
UserListView(
modifier = Modifier.fillMaxWidth(),
state = state.userListState,

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.impl
package io.element.android.features.createroom.impl.userlist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -28,24 +28,16 @@ import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.features.userlist.api.UserListDataStore
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.designsystem.theme.components.SearchBarResultState
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.api.user.MatrixUser
import kotlinx.collections.immutable.persistentListOf
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
class DefaultUserListPresenter @AssistedInject constructor(
@Assisted val args: UserListPresenterArgs,
@Assisted val userListDataSource: UserListDataSource,
@Assisted val userRepository: UserRepository,
@Assisted val userListDataStore: UserListDataStore,
) : UserListPresenter {
@ -54,7 +46,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
interface DefaultUserListFactory : UserListPresenter.Factory {
override fun create(
args: UserListPresenterArgs,
userListDataSource: UserListDataSource,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): DefaultUserListPresenter
}
@ -64,24 +56,18 @@ class DefaultUserListPresenter @AssistedInject constructor(
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: UserSearchResultState by remember {
mutableStateOf(UserSearchResultState.NotSearching)
var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
mutableStateOf(SearchBarResultState.NotSearching())
}
LaunchedEffect(searchQuery) {
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
searchResults = if (MatrixPatterns.isUserId(searchQuery)) {
UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(searchQuery))))
} else {
UserSearchResultState.NotSearching
}
searchResults = SearchBarResultState.NotSearching()
// Debounce
delay(args.searchDebouncePeriodMillis)
// Perform the search asynchronously
if (searchQuery.length >= args.minimumSearchLength) {
searchResults = performSearch(searchQuery)
userRepository.search(searchQuery).collect {
searchResults = when {
it.isEmpty() -> SearchBarResultState.NoResults()
else -> SearchBarResultState.Results(it.toImmutableList())
}
}
}
@ -101,15 +87,4 @@ class DefaultUserListPresenter @AssistedInject constructor(
},
)
}
private suspend fun performSearch(query: String): UserSearchResultState {
val isMatrixId = MatrixPatterns.isUserId(query)
val results = userListDataSource.search(query).toMutableList()
if (isMatrixId && results.none { it.userId.value == query }) {
val getProfileResult: MatrixUser? = userListDataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
return if (results.isEmpty()) UserSearchResultState.NoResults else UserSearchResultState.Results(results.toImmutableList())
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,16 +14,17 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
interface UserListPresenter : Presenter<UserListState> {
interface Factory {
fun create(
args: UserListPresenterArgs,
userListDataSource: UserListDataSource,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter
}

View file

@ -14,18 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
data class UserListPresenterArgs(
val selectionMode: SelectionMode,
val minimumSearchLength: Int = 1,
val searchDebouncePeriodMillis: Long = NO_DEBOUNCE,
) {
companion object {
const val NO_DEBOUNCE = 0L
const val DEFAULT_DEBOUNCE = 500L
}
}
)
enum class SelectionMode {
Single,

View file

@ -14,14 +14,15 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: UserSearchResultState,
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,
@ -29,14 +30,3 @@ 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

@ -14,9 +14,10 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -37,19 +38,19 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = UserSearchResultState.NoResults
searchResults = SearchBarResultState.NoResults()
),
)
}
@ -57,7 +58,7 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
fun aUserListState() = UserListState(
isSearchActive = false,
searchQuery = "",
searchResults = UserSearchResultState.NotSearching,
searchResults = SearchBarResultState.NotSearching(),
selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single,
eventSink = {}

View file

@ -21,9 +21,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@ -34,7 +34,11 @@ class AddPeoplePresenterTests {
@Before
fun setup() {
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeUserListDataSource(), CreateRoomDataStore(UserListDataStore()))
presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore())
)
}
@Test
@ -42,6 +46,7 @@ class AddPeoplePresenterTests {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem()
assertThat(initialState)
}

View file

@ -24,7 +24,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL

View file

@ -20,11 +20,10 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.UserListDataStore
import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.userlist.test.FakeUserListDataSource
import io.element.android.features.userlist.test.FakeUserListPresenter
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@ -32,6 +31,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Before
@ -39,7 +39,7 @@ import org.junit.Test
class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeUserListDataSource
private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@ -48,8 +48,8 @@ class CreateRoomRootPresenterTests {
fun setup() {
fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient()
userListDataSource = FakeUserListDataSource()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, UserListDataStore(), fakeMatrixClient)
userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userRepository, UserListDataStore(), fakeMatrixClient)
}
@Test

View file

@ -14,77 +14,78 @@
* limitations under the License.
*/
package io.element.android.features.userlist.impl
package io.element.android.features.createroom.impl.userlist
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
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.api.user.MatrixUser
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultUserListPresenterTests {
private val userListDataSource = FakeUserListDataSource()
private val userRepository = FakeUserRepository()
@Test
fun `present - initial state for single selection`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource,
UserListDataStore(),
)
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isFalse()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
}
}
@Test
fun `present - initial state for multiple selection`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userListDataSource,
UserListDataStore(),
)
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Multiple),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.isMultiSelectionEnabled).isTrue()
assertThat(initialState.isSearchActive).isFalse()
assertThat(initialState.selectedUsers).isEmpty()
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
}
}
@Test
fun `present - update search query`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource,
UserListDataStore(),
)
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.OnSearchActiveChanged(true))
@ -93,12 +94,14 @@ class DefaultUserListPresenterTests {
val matrixIdQuery = "@name:matrix.org"
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(matrixIdQuery)))))
assertThat(userRepository.providedQuery).isEqualTo(matrixIdQuery)
skipItems(1)
val notMatrixIdQuery = "name"
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NoResults)
assertThat(userRepository.providedQuery).isEqualTo(notMatrixIdQuery)
skipItems(1)
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
assertThat(awaitItem().isSearchActive).isFalse()
@ -106,39 +109,83 @@ class DefaultUserListPresenterTests {
}
@Test
fun `present - searches when minimum length exceeded`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single, minimumSearchLength = 3),
userListDataSource,
UserListDataStore(),
)
fun `present - presents search results`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
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())))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the user repository emits a result, it's copied to the state
userRepository.emitResult(listOf(aMatrixUser()))
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
persistentListOf(aMatrixUser())
)
)
// When the user repository emits another result, it replaces the previous value
userRepository.emitResult(aMatrixUserList())
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
aMatrixUserList()
)
)
}
}
@Test
fun `present - presents search results when not found`() = runTest {
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
assertThat(userRepository.providedQuery).isEqualTo("alice")
skipItems(2)
// When the results list is empty, the state is set to NoResults
userRepository.emitResult(emptyList())
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@Test
fun `present - select a user`() = runTest {
val presenter = DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userListDataSource,
UserListDataStore(),
)
val presenter =
DefaultUserListPresenter(
UserListPresenterArgs(selectionMode = SelectionMode.Single),
userRepository,
UserListDataStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
val userA = aMatrixUser("@userA:domain", "A")

View file

@ -14,12 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.userlist.test
package io.element.android.features.createroom.impl.userlist
import androidx.compose.runtime.Composable
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.aUserListState
class FakeUserListPresenter : UserListPresenter {

View file

@ -14,12 +14,9 @@
* limitations under the License.
*/
package io.element.android.features.userlist.test
package io.element.android.features.createroom.impl.userlist
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.libraries.usersearch.api.UserRepository
class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
@ -27,7 +24,7 @@ class FakeUserListPresenterFactory(
override fun create(
args: UserListPresenterArgs,
userListDataSource: UserListDataSource,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
): UserListPresenter = fakeUserListPresenter
}

View file

@ -17,7 +17,9 @@
package io.element.android.features.invitelist.impl
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -103,7 +105,7 @@ fun InviteListView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun InviteListContent(
state: InviteListState,
@ -124,7 +126,9 @@ fun InviteListContent(
},
content = { padding ->
Column(
modifier = Modifier.padding(padding)
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp))

View file

@ -20,7 +20,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -81,7 +83,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class, ExperimentalLayoutApi::class)
@Composable
fun ChangeServerView(
state: ChangeServerState,
@ -121,6 +123,7 @@ fun ChangeServerView(
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
Column(
modifier = Modifier

View file

@ -21,8 +21,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -86,7 +88,7 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun LoginRootView(
state: LoginRootState,
@ -113,6 +115,7 @@ fun LoginRootView(
.fillMaxSize()
.imePadding()
.padding(padding)
.consumeWindowInsets(padding)
) {
val scrollState = rememberScrollState()

View file

@ -19,9 +19,11 @@ package io.element.android.features.messages.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@ -79,7 +81,7 @@ import io.element.android.libraries.designsystem.utils.LogCompositions
import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class)
@Composable
fun MessagesView(
state: MessagesState,
@ -183,7 +185,9 @@ fun MessagesView(
content = { padding ->
MessagesViewContent(
state = state,
modifier = Modifier.padding(padding),
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
onMessageClicked = ::onMessageClicked,
onMessageLongClicked = ::onMessageLongClicked
)

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
implementation(libs.coil.compose)
testImplementation(libs.test.junit)
@ -50,7 +51,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)

View file

@ -35,7 +35,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)

View file

@ -19,8 +19,10 @@ package io.element.android.features.roomdetails.impl
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -43,8 +45,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading
@ -68,7 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun RoomDetailsView(
state: RoomDetailsState,
@ -91,6 +93,7 @@ fun RoomDetailsView(
) { padding ->
Column(modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState())
) {
when (state.roomType) {

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.roomdetails.blockuser
package io.element.android.features.roomdetails.impl.blockuser
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext
@ -41,7 +42,7 @@ class RoomMemberListPresenter @Inject constructor(
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember {
mutableStateOf<RoomMemberSearchResultState>(RoomMemberSearchResultState.NotSearching)
mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.NotSearching())
}
var isSearchActive by rememberSaveable { mutableStateOf(false) }
@ -60,11 +61,11 @@ class RoomMemberListPresenter @Inject constructor(
LaunchedEffect(searchQuery) {
withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty()) {
RoomMemberSearchResultState.NotSearching
SearchBarResultState.NotSearching()
} else {
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
if (results.isEmpty()) RoomMemberSearchResultState.NoResults
else RoomMemberSearchResultState.Results(
if (results.isEmpty()) SearchBarResultState.NoResults()
else SearchBarResultState.Results(
RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),

View file

@ -17,13 +17,14 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RoomMemberListState(
val roomMembers: Async<RoomMembers>,
val searchQuery: String,
val searchResults: RoomMemberSearchResultState,
val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean,
val eventSink: (RoomMemberListEvents) -> Unit,
)
@ -32,14 +33,3 @@ data class RoomMembers(
val invited: ImmutableList<RoomMember>,
val joined: ImmutableList<RoomMember>
)
sealed interface RoomMemberSearchResultState {
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
object NotSearching : RoomMemberSearchResultState
/** The search has completed, but no results were found. */
object NoResults : RoomMemberSearchResultState
/** The search has completed, and some matching users were found. */
data class Results(val results: RoomMembers) : RoomMemberSearchResultState
}

View file

@ -18,11 +18,11 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
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.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState>
@ -42,7 +42,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = RoomMemberSearchResultState.Results(
searchResults = SearchBarResultState.Results(
RoomMembers(
invited = persistentListOf(aVictor()),
joined = persistentListOf(anAlice()),
@ -52,14 +52,14 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = RoomMemberSearchResultState.NoResults
searchResults = SearchBarResultState.NoResults()
),
)
}
internal fun aRoomMemberListState(
roomMembers: Async<RoomMembers> = Async.Uninitialized,
searchResults: RoomMemberSearchResultState = RoomMemberSearchResultState.NotSearching,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.NotSearching(),
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",

View file

@ -20,27 +20,20 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
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.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -59,10 +52,9 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
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.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
@ -71,6 +63,7 @@ import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
@ -93,21 +86,20 @@ fun RoomMemberListView(
Column(
modifier = modifier
.fillMaxWidth()
.padding(padding),
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column {
RoomMemberSearchBar(
query = state.searchQuery,
state = state.searchResults,
active = state.isSearchActive,
placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onUserSelected = ::onUserSelected,
modifier = Modifier.fillMaxWidth()
)
}
RoomMemberSearchBar(
query = state.searchQuery,
state = state.searchResults,
active = state.isSearchActive,
placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onUserSelected = ::onUserSelected,
modifier = Modifier.fillMaxWidth()
)
if (!state.isSearchActive) {
if (state.roomMembers is Async.Success) {
@ -216,11 +208,10 @@ private fun RoomMemberListTopBar(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberSearchBar(
query: String,
state: RoomMemberSearchResultState,
state: SearchBarResultState<RoomMembers>,
active: Boolean,
placeHolderTitle: String,
onActiveChanged: (Boolean) -> Unit,
@ -228,72 +219,21 @@ private fun RoomMemberSearchBar(
onUserSelected: (RoomMember) -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChanged,
modifier = modifier
.padding(horizontal = if (!active) 16.dp else 0.dp),
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
modifier = modifier,
placeHolderTitle = placeHolderTitle,
resultState = state,
resultHandler = { results ->
RoomMemberList(
roomMembers = results,
showMembersCount = false,
onUserSelected = { onUserSelected(it) }
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(StringR.string.action_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(StringR.string.action_search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
if (state is RoomMemberSearchResultState.Results) {
RoomMemberList(
roomMembers = state.results,
showMembersCount = false,
onUserSelected = onUserSelected
)
} else if (state is RoomMemberSearchResultState.NoResults) {
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(StringR.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
},
)
}

View file

@ -19,8 +19,10 @@ package io.element.android.features.roomdetails.impl.members.details
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -39,8 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
@ -57,7 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun RoomMemberDetailsView(
state: RoomMemberDetailsState,
@ -74,6 +76,7 @@ fun RoomMemberDetailsView(
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState())
) {
RoomMemberHeaderSection(

View file

@ -24,12 +24,12 @@ import io.element.android.features.roomdetails.aMatrixRoom
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents
import io.element.android.features.roomdetails.impl.members.RoomMemberListPresenter
import io.element.android.features.roomdetails.impl.members.RoomMemberSearchResultState
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.tests.testutils.testCoroutineDispatchers
@ -49,7 +49,7 @@ class RoomMemberListPresenterTests {
val initialState = awaitItem()
Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
Truth.assertThat(initialState.searchQuery).isEmpty()
Truth.assertThat(initialState.searchResults).isEqualTo(RoomMemberSearchResultState.NotSearching)
Truth.assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.NotSearching::class.java)
Truth.assertThat(initialState.isSearchActive).isFalse()
val loadedState = awaitItem()
@ -89,7 +89,7 @@ class RoomMemberListPresenterTests {
val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
val searchSearchResultDelivered = awaitItem()
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.NoResults::class.java)
Truth.assertThat(searchSearchResultDelivered.searchResults).isInstanceOf(SearchBarResultState.NoResults::class.java)
}
}
@ -107,8 +107,8 @@ class RoomMemberListPresenterTests {
val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem()
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java)
Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName)
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(SearchBarResultState.Results::class.java)
Truth.assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName)
.isEqualTo("Alice")
}

View file

@ -22,9 +22,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -116,7 +118,7 @@ fun RoomListView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun RoomListContent(
state: RoomListState,
@ -190,6 +192,7 @@ fun RoomListContent(
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(
modifier = Modifier

View file

@ -20,6 +20,8 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -89,7 +91,7 @@ internal fun RoomListSearchResultView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
internal fun RoomListSearchResultContent(
state: RoomListState,
@ -183,6 +185,7 @@ internal fun RoomListSearchResultContent(
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
LazyColumn(
modifier = Modifier

View file

@ -1,163 +0,0 @@
/*
* 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.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
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
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.ui.strings.R
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchUserBar(
query: String,
state: UserSearchResultState,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
modifier: Modifier = Modifier,
placeHolderTitle: String = stringResource(R.string.common_search_for_someone),
onActiveChanged: (Boolean) -> Unit = {},
onTextChanged: (String) -> Unit = {},
onUserSelected: (MatrixUser) -> Unit = {},
onUserDeselected: (MatrixUser) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar(
query = query,
onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChanged,
modifier = modifier
.padding(horizontal = if (!active) 16.dp else 0.dp),
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChanged(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onTextChanged("") }) {
Icon(Icons.Default.Close, stringResource(R.string.action_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.action_search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
content = {
if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) {
SelectedUsersList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
onUserRemoved = onUserDeselected,
)
}
if (state is UserSearchResultState.Results) {
LazyColumn {
if (isMultiSelectionEnabled) {
items(state.results) { matrixUser ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
} else {
onUserDeselected(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

@ -18,8 +18,10 @@ package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
@ -45,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PreferenceView(
title: String,
@ -69,6 +72,7 @@ fun PreferenceView(
Column(
modifier = Modifier
.padding(it)
.consumeWindowInsets(it)
.verticalScroll(
state = scrollState,
)

View file

@ -16,74 +16,216 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.SearchBarColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.ui.strings.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
fun <T> SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
active: Boolean,
onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable ColumnScope.() -> Unit,
contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(T) -> Unit = {},
) {
val focusManager = LocalFocusManager.current
if (!active) {
onQueryChange("")
focusManager.clearFocus()
}
androidx.compose.material3.SearchBar(
query = query,
onQueryChange = onQueryChange,
onSearch = onSearch,
onSearch = { focusManager.clearFocus() },
active = active,
onActiveChange = onActiveChange,
modifier = modifier,
modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
enabled = enabled,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
placeholder = {
Text(
text = placeHolderTitle,
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
},
leadingIcon = if (active) {
{ BackButton(onClick = { onActiveChange(false) }) }
} else {
null
},
trailingIcon = when {
active && query.isNotEmpty() -> {
{
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Close, stringResource(R.string.action_clear))
}
}
}
!active -> {
{
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.action_search),
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine)
)
}
}
else -> null
},
shape = shape,
colors = colors,
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
tonalElevation = tonalElevation,
windowInsets = windowInsets,
interactionSource = interactionSource,
content = content,
content = {
contentPrefix()
when (resultState) {
is SearchBarResultState.Results<T> -> {
resultHandler(resultState.results)
}
is SearchBarResultState.NoResults<T> -> {
// No results found, show a message
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(R.string.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
else -> {
// Not searching - nothing to show.
}
}
contentSuffix()
},
)
}
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>
/** The search has completed, but no results were found. */
class NoResults<T> : SearchBarResultState<T>
/** The search has completed, and some matching users were found. */
data class Results<T>(val results: T) : SearchBarResultState<T>
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewInactive() = ElementThemedPreview { ContentToPreview() }
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveEmptyQuery() = ElementThemedPreview {
ContentToPreview(
query = "",
active = true,
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreview() = ElementThemedPreview { ContentToPreview() }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ContentToPreview() {
SearchBar(
query = "Some text",
onQueryChange = {},
onSearch = {},
active = false,
onActiveChange = {},
content = {},
internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
resultState = SearchBarResultState.NoResults(),
)
}
@Preview(group = PreviewGroup.Search)
@Composable
internal fun SearchBarPreviewActiveWithContent() = ElementThemedPreview {
ContentToPreview(
query = "search term",
active = true,
resultState = SearchBarResultState.Results("result!"),
contentPrefix = {
Text(text = "Content that goes before the search results", modifier = Modifier.background(color = Color.Red).fillMaxWidth())
},
contentSuffix = {
Text(text = "Content that goes after the search results", modifier = Modifier.background(color = Color.Blue).fillMaxWidth())
},
resultHandler = {
Text(text = "Results go here", modifier = Modifier.background(color = Color.Green).fillMaxWidth())
}
)
}
@Composable
private fun ContentToPreview(
query: String = "",
active: Boolean = false,
resultState: SearchBarResultState<String> = SearchBarResultState.NotSearching(),
contentPrefix: @Composable ColumnScope.() -> Unit = {},
contentSuffix: @Composable ColumnScope.() -> Unit = {},
resultHandler: @Composable ColumnScope.(String) -> Unit = {},
) {
SearchBar(
query = query,
active = active,
resultState = resultState,
onQueryChange = {},
onActiveChange = {},
placeHolderTitle = "Search for things",
contentPrefix = contentPrefix,
contentSuffix = contentSuffix,
resultHandler = resultHandler,
)
}

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
ksp(libs.showkase.processor)

View file

@ -26,11 +26,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun CheckableMatrixUserRow(
@ -40,18 +43,39 @@ fun CheckableMatrixUserRow(
avatarSize: AvatarSize = AvatarSize.MEDIUM,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
) = CheckableUserRow(
checked = checked,
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
@Composable
fun CheckableUserRow(
checked: Boolean,
avatarData: AvatarData,
name: String,
subtext: String?,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox) { onCheckedChange(!checked) },
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
MatrixUserRow(
UserRow(
modifier = Modifier.weight(1f),
matrixUser = matrixUser,
avatarSize = avatarSize,
avatarData = avatarData,
name = name,
subtext = subtext,
)
Checkbox(
@ -77,5 +101,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
Column {
CheckableMatrixUserRow(checked = true, matrixUser)
CheckableMatrixUserRow(checked = false, matrixUser)
CheckableMatrixUserRow(checked = true, matrixUser, enabled = false)
CheckableMatrixUserRow(checked = false, matrixUser, enabled = false)
}
}

View file

@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -46,6 +47,19 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
)
@Composable
fun UserRow(
avatarData: AvatarData,
name: String,
subtext: String?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
@ -54,9 +68,7 @@ fun MatrixUserRow(
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
matrixUser.getAvatarData(size = avatarSize),
)
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
@ -65,15 +77,15 @@ fun MatrixUserRow(
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = matrixUser.getBestName(),
text = name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
)
// Id
if (matrixUser.displayName.isNullOrEmpty().not()) {
subtext?.let {
Text(
text = matrixUser.userId.value,
text = subtext,
color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp,
maxLines = 1,

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api.components
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -41,10 +41,9 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun SelectedUser(
@ -74,7 +73,7 @@ fun SelectedUser(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.action_remove),
contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api.components
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@ -30,11 +30,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.aListOfSelectedUsers
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SelectedUsersList(
@ -82,6 +82,6 @@ internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPrev
@Composable
private fun ContentToPreview() {
SelectedUsersList(
selectedUsers = aListOfSelectedUsers(),
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
)
}

View file

@ -85,14 +85,14 @@
<string name="common_report_a_bug">"Report a bug"</string>
<string name="common_report_submitted">"Report submitted"</string>
<string name="common_search_for_someone">"Search for someone"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_security">"Security"</string>
<string name="common_select_your_server">"Select your server"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_starting_chat">"Starting chat…"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Success"</string>
<string name="common_suggestions">"Suggestions"</string>

View file

@ -13,20 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.userlist.api"
namespace = "io.element.android.libraries.usersearch.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
ksp(libs.showkase.processor)
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.userlist.api
package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,22 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl.di
package io.element.android.libraries.usersearch.api
import com.squareup.anvil.annotations.ContributesTo
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.SessionScope
import javax.inject.Named
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
@Module
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource
interface UserRepository {
suspend fun search(query: String): Flow<List<MatrixUser>>
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@ -15,13 +15,12 @@
*/
plugins {
id("io.element.android-compose-library")
id("io.element.android-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.userlist.impl"
namespace = "io.element.android.libraries.usersearch.impl"
}
anvil {
@ -29,28 +28,18 @@ anvil {
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
api(projects.features.userlist.api)
ksp(libs.showkase.processor)
implementation(projects.libraries.matrix.api)
api(projects.libraries.usersearch.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test)
androidTestImplementation(libs.test.junitext)
testImplementation(projects.libraries.usersearch.test)
}

View file

@ -14,15 +14,18 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl
package io.element.android.libraries.usersearch.impl
import io.element.android.features.userlist.api.UserListDataSource
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import javax.inject.Inject
class AllMatrixUsersDataSource @Inject constructor(
@ContributesBinding(SessionScope::class)
class MatrixUserListDataSource @Inject constructor(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {

View file

@ -0,0 +1,64 @@
/*
* 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.usersearch.impl
import com.squareup.anvil.annotations.ContributesBinding
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.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource
) : UserRepository {
override suspend fun search(query: String): Flow<List<MatrixUser>> = flow {
// Manually add a fake result with the matrixId, if any
val isUserId = MatrixPatterns.isUserId(query)
if (isUserId) {
emit(listOf(MatrixUser(UserId(query))))
}
if (query.length >= MINIMUM_SEARCH_LENGTH) {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource.search(query).toMutableList()
// If the query is a user ID and the result doesn't contain that user ID, query the profile information explicitly
if (isUserId && results.none { it.userId.value == query }) {
val getProfileResult: MatrixUser? = dataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
}
emit(results)
}
}
companion object {
private const val DEBOUNCE_TIME_MILLIS = 500L
private const val MINIMUM_SEARCH_LENGTH = 3
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.features.createroom.impl
package io.element.android.libraries.usersearch.impl
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId
@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class AllMatrixUsersDataSourceTest {
internal class MatrixUserListDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
@ -37,15 +37,21 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
results = listOf(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
),
limited = false
)
)
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2))
Truth.assertThat(results).containsExactly(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
)
}
@Test
@ -55,7 +61,7 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test",
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).isEmpty()
@ -68,7 +74,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID,
result = Result.success(aMatrixUserProfile())
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
@ -81,7 +87,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull()

View file

@ -0,0 +1,140 @@
/*
* 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.usersearch.impl
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class MatrixUserRepositoryTest {
@Test
fun `search - emits nothing if the search query is too short`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search("x")
result.test {
awaitComplete()
}
}
@Test
fun `search - returns empty list if no results are found`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEmpty()
awaitComplete()
}
}
@Test
fun `search - returns users if results are found`() = runTest {
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(aMatrixUserList())
val repository = MatrixUserRepository(dataSource)
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEqualTo(aMatrixUserList())
awaitComplete()
}
}
@Test
fun `search - immediately returns placeholder if search is mxid`() = runTest {
val dataSource = FakeUserListDataSource()
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)))
skipItems(1)
awaitComplete()
}
}
@Test
fun `search - does not change results if they contain searched mxid`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID) + MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(searchResults)
awaitComplete()
}
}
@Test
fun `search - gets profile results if searched mxid not in results`() = runTest {
val userProfile = MatrixUser(userId = A_USER_ID, displayName = A_USER_NAME)
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(userProfile)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(userProfile) + searchResults)
awaitComplete()
}
}
@Test
fun `search - just shows id if profile can't be loaded`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
dataSource.givenSearchResult(searchResults)
dataSource.givenUserProfile(null)
val repository = MatrixUserRepository(dataSource)
val result = repository.search(A_USER_ID.value)
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)) + searchResults)
awaitComplete()
}
}
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId }
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2022 New Vector Ltd
* 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.
@ -13,18 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-compose-library")
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.userlist.test"
namespace = "io.element.android.libraries.usersearch"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api)
api(libs.coroutines.core)
api(projects.libraries.usersearch.api)
}

View file

@ -14,11 +14,11 @@
* limitations under the License.
*/
package io.element.android.features.userlist.test
package io.element.android.libraries.usersearch.test
import io.element.android.features.userlist.api.UserListDataSource
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
class FakeUserListDataSource : UserListDataSource {

View file

@ -0,0 +1,40 @@
/*
* 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.usersearch.test
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class FakeUserRepository : UserRepository {
var providedQuery: String? = null
private set
private val flow = MutableSharedFlow<List<MatrixUser>>()
override suspend fun search(query: String): Flow<List<MatrixUser>> {
providedQuery = query
return flow
}
suspend fun emitResult(result: List<MatrixUser>) {
flow.emit(result)
}
}

View file

@ -90,6 +90,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:statemachine"))
implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
}
fun DependencyHandlerScope.allServicesImpl() {

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cbfbfa08085539bbfa4f20bc5d149bf0db0036169a32747c96f956d0d0f4b42d
size 93741
oid sha256:b7787897d8dda469f17da7151260f26b6dcc5500f93ce44fb317af42ae8d457c
size 93739

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9e0ba60e784a0dc582826a3f5679a6015531dc9aca22617ec1e0dede38b97f30
size 93678
oid sha256:6691512398d2c7e319d15397120054f1c2aae452ffed0be47df72ecb906ac4fa
size 93675

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:766b2d44b51a36d1b15d8a39434ce5237f8427d1150f9b4c1255043fc1e4bb79
size 492165
oid sha256:44313efc4d87857b9eb0bb05c8d24330b17353fd95344bcce06c1ad1bba8df81
size 492167

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:766b2d44b51a36d1b15d8a39434ce5237f8427d1150f9b4c1255043fc1e4bb79
size 492165
oid sha256:44313efc4d87857b9eb0bb05c8d24330b17353fd95344bcce06c1ad1bba8df81
size 492167

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3443f60b8ceec7eccfee92c1bca8cad151e25cff8e7845459a5f4c3d50e5e08
size 3330
oid sha256:523d6607d70a331d2ec32aeabf033d59c4dc60e0e7373982893df0245ae924ce
size 3332

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0b0cdb7e39d5ca5ce874477da4397ebed00ecf9f5d8980f9f3b03ab72259d9f
size 4000
oid sha256:266490a1bdce29ac65d875543deda072d7f2a9aa2e216efb0d54b93c2a11aa5f
size 4001

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a3443f60b8ceec7eccfee92c1bca8cad151e25cff8e7845459a5f4c3d50e5e08
size 3330
oid sha256:523d6607d70a331d2ec32aeabf033d59c4dc60e0e7373982893df0245ae924ce
size 3332

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0b0cdb7e39d5ca5ce874477da4397ebed00ecf9f5d8980f9f3b03ab72259d9f
size 4000
oid sha256:266490a1bdce29ac65d875543deda072d7f2a9aa2e216efb0d54b93c2a11aa5f
size 4001

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d35eb1aac0d413c216dc77f45f655be97ae9d36c3cade57ee8f70e25c44fafd1
size 3142
oid sha256:e94515b43bc4b8427edc60456ec657ec38b33539a034a2c45c92624a6b75d4e8
size 3138

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:782a439312e4b616a6dd3020d8c99d04042208b2ebc416fab73c95344adeba67
size 3834
oid sha256:a4dc928a04bfc6331673be2fa0bf7f12e384dfd8ff91b07dc57b220c8dbbdd71
size 3829

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d35eb1aac0d413c216dc77f45f655be97ae9d36c3cade57ee8f70e25c44fafd1
size 3142
oid sha256:e94515b43bc4b8427edc60456ec657ec38b33539a034a2c45c92624a6b75d4e8
size 3138

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:782a439312e4b616a6dd3020d8c99d04042208b2ebc416fab73c95344adeba67
size 3834
oid sha256:a4dc928a04bfc6331673be2fa0bf7f12e384dfd8ff91b07dc57b220c8dbbdd71
size 3829

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6cab21ee595c96700e4577554f9c98fdc56a59bf5093aa9d81a54d10381ba122
size 6065
oid sha256:25d0334932490997d432115c550c42e74e7226399ef82031430bf86ba33972bd
size 6066

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b9235621126fe90a51d557df616878d53120dada814830fba006b54f1556e69e
size 5895
oid sha256:a2d15387248308e5234c84b9b2b282e736f05f0495c80807bacfb0acff31eb2b
size 5890

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:562e83b7771ff2209c7882180af55a8f3123c12c020b294858b1b46bff8a8a95
size 30928
oid sha256:5315b975f8b9def0b0d7575160cb34300511f1b83845ee2d1762188481cf6fe9
size 30927

Some files were not shown because too many files have changed in this diff Show more