Merge remote-tracking branch 'origin/develop' into feature/fre/invite_people_action

This commit is contained in:
Chris Smith
2023-05-22 15:35:25 +01:00
138 changed files with 1022 additions and 697 deletions
+2 -3
View File
@@ -46,10 +46,10 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
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)
@@ -60,10 +60,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)
@@ -1,40 +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.createroom.impl
import io.element.android.features.userlist.api.UserListDataSource
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 javax.inject.Inject
class AllMatrixUsersDataSource @Inject constructor(
private val client: MatrixClient
) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> {
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
return res.getOrNull()?.results.orEmpty()
}
override suspend fun getProfile(userId: UserId): MatrixUser? {
return client.getProfile(userId).getOrNull()
}
companion object {
private const val MAX_SEARCH_RESULTS = 5L
}
}
@@ -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
@@ -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,
)
}
@@ -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,
@@ -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,
)
}
@@ -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
@@ -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
@@ -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) }
)
}
}
}
},
)
}
@@ -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(
@@ -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
@@ -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 {
@@ -1,35 +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.createroom.impl.di
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
@Module
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource
}
@@ -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,
)
}
@@ -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
@@ -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,
)
@@ -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,
@@ -79,10 +81,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,
@@ -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())
}
}
@@ -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
@@ -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
@@ -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
}
@@ -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,
@@ -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
}
@@ -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 = {}
@@ -1,95 +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.createroom.impl
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import kotlinx.coroutines.test.runTest
import org.junit.Test
internal class AllMatrixUsersDataSourceTest {
@Test
fun `search - returns users on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.success(
MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
limited = false
)
)
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).containsExactly(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2))
}
@Test
fun `search - returns empty list on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenSearchUsersResult(
searchTerm = "test",
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val results = dataSource.search("test")
Truth.assertThat(results).isEmpty()
}
@Test
fun `get profile - returns user on success`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.success(aMatrixUserProfile())
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
}
@Test
fun `get profile - returns null on error`() = runTest {
val matrixClient = FakeMatrixClient()
matrixClient.givenGetProfileResult(
userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh"))
)
val dataSource = AllMatrixUsersDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull()
}
private fun aMatrixUserProfile(
userId: UserId = A_USER_ID,
displayName: String = A_USER_NAME,
avatarUrl: String = AN_AVATAR_URL
) = MatrixUser(userId, displayName, avatarUrl)
}
@@ -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)
}
@@ -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
@@ -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
@@ -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")
@@ -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 {
@@ -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
}
@@ -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))
@@ -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
@@ -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()
@@ -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
)
+1 -1
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)
@@ -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)
@@ -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) {
@@ -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
@@ -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(),
@@ -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
}
@@ -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 = "",
@@ -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()
)
}
},
)
}
@@ -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(
@@ -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")
}
@@ -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
@@ -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
-32
View File
@@ -1,32 +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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.userlist.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)
}
@@ -1,26 +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
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
interface UserListDataSource {
//TODO should probably have a flow
suspend fun search(query: String): List<MatrixUser>
suspend fun getProfile(userId: UserId): MatrixUser?
}
@@ -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()
)
}
},
)
}
@@ -1,95 +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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.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
@Composable
fun SelectedUser(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
onUserRemoved: (MatrixUser) -> Unit = {},
) {
Box(modifier = modifier.width(56.dp)) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(matrixUser.getAvatarData(size = AvatarSize.Custom(56.dp)))
Text(
text = matrixUser.getBestName(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
)
}
IconButton(
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.size(20.dp)
.align(Alignment.TopEnd),
onClick = { onUserRemoved(matrixUser) }
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
@Preview
@Composable
internal fun SelectedUserLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SelectedUserDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SelectedUser(aMatrixUser())
}
@@ -1,87 +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.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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
@Composable
fun SelectedUsersList(
selectedUsers: ImmutableList<MatrixUser>,
modifier: Modifier = Modifier,
autoScroll: Boolean = false,
contentPadding: PaddingValues = PaddingValues(0.dp),
onUserRemoved: (MatrixUser) -> Unit = {},
) {
val lazyListState = rememberLazyListState()
if (autoScroll) {
var currentSize by rememberSaveable { mutableStateOf(selectedUsers.size) }
LaunchedEffect(selectedUsers.size) {
val isItemAdded = selectedUsers.size > currentSize
if (isItemAdded) {
lazyListState.animateScrollToItem(selectedUsers.lastIndex)
}
currentSize = selectedUsers.size
}
}
LazyRow(
state = lazyListState,
modifier = modifier,
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
items(selectedUsers.toList()) { matrixUser ->
SelectedUser(
matrixUser = matrixUser,
onUserRemoved = onUserRemoved,
)
}
}
}
@Preview
@Composable
internal fun SelectedUsersListLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SelectedUsersListDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
SelectedUsersList(
selectedUsers = aListOfSelectedUsers(),
)
}
-56
View File
@@ -1,56 +0,0 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
}
android {
namespace = "io.element.android.features.userlist.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
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)
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)
}
-30
View File
@@ -1,30 +0,0 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.userlist.test"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api)
api(libs.coroutines.core)
}
@@ -1,39 +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.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
class FakeUserListDataSource : UserListDataSource {
private var searchResult: List<MatrixUser> = emptyList()
private var profile: MatrixUser? = null
override suspend fun search(query: String): List<MatrixUser> = searchResult
override suspend fun getProfile(userId: UserId): MatrixUser? = profile
fun givenSearchResult(users: List<MatrixUser>) {
this.searchResult = users
}
fun givenUserProfile(matrixUser: MatrixUser?) {
this.profile = matrixUser
}
}