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
commit 25eee69bb5
138 changed files with 1022 additions and 697 deletions

View file

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

View file

@ -19,7 +19,7 @@ package io.element.android.features.createroom.impl
import android.net.Uri import android.net.Uri
import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.configureroom.RoomPrivacy
import io.element.android.features.createroom.impl.di.CreateRoomScope 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 io.element.android.libraries.di.SingleIn
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow 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 androidx.compose.runtime.Composable
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.SelectionMode import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.userlist.api.UserListDataSource import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named
class AddPeoplePresenter @Inject constructor( class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory, private val userListPresenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val userListDataSource: UserListDataSource, private val userRepository: UserRepository,
private val dataStore: CreateRoomDataStore, private val dataStore: CreateRoomDataStore,
) : Presenter<UserListState> { ) : Presenter<UserListState> {
@ -37,10 +36,8 @@ class AddPeoplePresenter @Inject constructor(
userListPresenterFactory.create( userListPresenterFactory.create(
UserListPresenterArgs( UserListPresenterArgs(
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
minimumSearchLength = 3,
searchDebouncePeriodMillis = UserListPresenterArgs.DEFAULT_DEBOUNCE
), ),
userListDataSource, userRepository,
dataStore.selectedUserListDataStore, dataStore.selectedUserListDataStore,
) )
} }

View file

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

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.foundation.clickable
import androidx.compose.runtime.Composable 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. * 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.Column
import androidx.compose.foundation.layout.PaddingValues 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.userlist.api.UserListEvents import io.element.android.features.createroom.impl.userlist.UserListEvents
import io.element.android.features.userlist.api.UserListState import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.userlist.api.UserListStateProvider import io.element.android.features.createroom.impl.userlist.UserListStateProvider
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
@Composable @Composable
fun UserListView( fun UserListView(

View file

@ -18,7 +18,7 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig 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 io.element.android.libraries.architecture.Async
import kotlinx.collections.immutable.persistentListOf 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.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectableGroup 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.LabelledTextField
import io.element.android.features.createroom.impl.components.RoomPrivacyOption import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarActionListView 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.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton 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.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterialApi::class)
@Composable @Composable
fun ConfigureRoomView( fun ConfigureRoomView(
state: ConfigureRoomState, state: ConfigureRoomState,
@ -104,7 +106,9 @@ fun ConfigureRoomView(
} }
) { padding -> ) { padding ->
LazyColumn( LazyColumn(
modifier = Modifier.padding(padding), modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
item { item {

View file

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

View file

@ -16,7 +16,7 @@
package io.element.android.features.createroom.impl.root 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.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,16 +14,17 @@
* limitations under the License. * 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.architecture.Presenter
import io.element.android.libraries.usersearch.api.UserRepository
interface UserListPresenter : Presenter<UserListState> { interface UserListPresenter : Presenter<UserListState> {
interface Factory { interface Factory {
fun create( fun create(
args: UserListPresenterArgs, args: UserListPresenterArgs,
userListDataSource: UserListDataSource, userRepository: UserRepository,
userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
): UserListPresenter ): UserListPresenter
} }

View file

@ -14,18 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.userlist.api package io.element.android.features.createroom.impl.userlist
data class UserListPresenterArgs( data class UserListPresenterArgs(
val selectionMode: SelectionMode, 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 { enum class SelectionMode {
Single, Single,

View file

@ -14,14 +14,15 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class UserListState( data class UserListState(
val searchQuery: String, val searchQuery: String,
val searchResults: UserSearchResultState, val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, val selectionMode: SelectionMode,
@ -29,14 +30,3 @@ data class UserListState(
) { ) {
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple 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. * 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 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 io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -37,19 +38,19 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
), ),
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple, selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
), ),
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,
searchQuery = "something-with-no-results", searchQuery = "something-with-no-results",
searchResults = UserSearchResultState.NoResults searchResults = SearchBarResultState.NoResults()
), ),
) )
} }
@ -57,7 +58,7 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
fun aUserListState() = UserListState( fun aUserListState() = UserListState(
isSearchActive = false, isSearchActive = false,
searchQuery = "", searchQuery = "",
searchResults = UserSearchResultState.NotSearching, searchResults = SearchBarResultState.NotSearching(),
selectedUsers = persistentListOf(), selectedUsers = persistentListOf(),
selectionMode = SelectionMode.Single, selectionMode = SelectionMode.Single,
eventSink = {} eventSink = {}

View file

@ -21,9 +21,9 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListPresenterFactory import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@ -34,7 +34,11 @@ class AddPeoplePresenterTests {
@Before @Before
fun setup() { fun setup() {
presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeUserListDataSource(), CreateRoomDataStore(UserListDataStore())) presenter = AddPeoplePresenter(
FakeUserListPresenterFactory(),
FakeUserRepository(),
CreateRoomDataStore(UserListDataStore())
)
} }
@Test @Test
@ -42,6 +46,7 @@ class AddPeoplePresenterTests {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// TODO This doesn't actually test anything...
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState) 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.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction 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.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_AVATAR_URL 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.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.UserListDataStore import io.element.android.features.createroom.impl.userlist.FakeUserListPresenter
import io.element.android.features.userlist.api.aUserListState import io.element.android.features.createroom.impl.userlist.FakeUserListPresenterFactory
import io.element.android.features.userlist.test.FakeUserListDataSource import io.element.android.features.createroom.impl.userlist.UserListDataStore
import io.element.android.features.userlist.test.FakeUserListPresenter import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@ -39,7 +39,7 @@ import org.junit.Test
class CreateRoomRootPresenterTests { class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeUserListDataSource private lateinit var userRepository: FakeUserRepository
private lateinit var presenter: CreateRoomRootPresenter private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient private lateinit var fakeMatrixClient: FakeMatrixClient
@ -48,8 +48,8 @@ class CreateRoomRootPresenterTests {
fun setup() { fun setup() {
fakeUserListPresenter = FakeUserListPresenter() fakeUserListPresenter = FakeUserListPresenter()
fakeMatrixClient = FakeMatrixClient() fakeMatrixClient = FakeMatrixClient()
userListDataSource = FakeUserListDataSource() userRepository = FakeUserRepository()
presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, UserListDataStore(), fakeMatrixClient) presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userRepository, UserListDataStore(), fakeMatrixClient)
} }
@Test @Test

View file

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

View file

@ -14,12 +14,9 @@
* limitations under the License. * 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 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 { class FakeUserListPresenter : UserListPresenter {

View file

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

View file

@ -17,7 +17,9 @@
package io.element.android.features.invitelist.impl package io.element.android.features.invitelist.impl
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -103,7 +105,7 @@ fun InviteListView(
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun InviteListContent( fun InviteListContent(
state: InviteListState, state: InviteListState,
@ -124,7 +126,9 @@ fun InviteListContent(
}, },
content = { padding -> content = { padding ->
Column( Column(
modifier = Modifier.padding(padding) modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) { ) {
if (state.inviteList.isEmpty()) { if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp)) 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.testtags.testTag
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalTextApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun ChangeServerView( fun ChangeServerView(
state: ChangeServerState, state: ChangeServerState,
@ -121,6 +123,7 @@ fun ChangeServerView(
.fillMaxSize() .fillMaxSize()
.imePadding() .imePadding()
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier

View file

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

View file

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

View file

@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
api(projects.features.roomdetails.api) api(projects.features.roomdetails.api)
api(projects.libraries.usersearch.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
@ -50,7 +51,6 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor) 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.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection 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.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading 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.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomDetailsView( fun RoomDetailsView(
state: RoomDetailsState, state: RoomDetailsState,
@ -91,6 +93,7 @@ fun RoomDetailsView(
) { padding -> ) { padding ->
Column(modifier = Modifier Column(modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
when (state.roomType) { when (state.roomType) {

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.Icons
import androidx.compose.material.icons.outlined.Block 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.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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 io.element.android.libraries.matrix.api.room.RoomMembershipState
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -41,7 +42,7 @@ class RoomMemberListPresenter @Inject constructor(
var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) } var roomMembers by remember { mutableStateOf<Async<RoomMembers>>(Async.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember { var searchResults by remember {
mutableStateOf<RoomMemberSearchResultState>(RoomMemberSearchResultState.NotSearching) mutableStateOf<SearchBarResultState<RoomMembers>>(SearchBarResultState.NotSearching())
} }
var isSearchActive by rememberSaveable { mutableStateOf(false) } var isSearchActive by rememberSaveable { mutableStateOf(false) }
@ -60,11 +61,11 @@ class RoomMemberListPresenter @Inject constructor(
LaunchedEffect(searchQuery) { LaunchedEffect(searchQuery) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
searchResults = if (searchQuery.isEmpty()) { searchResults = if (searchQuery.isEmpty()) {
RoomMemberSearchResultState.NotSearching SearchBarResultState.NotSearching()
} else { } else {
val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership } val results = roomMemberListDataSource.search(searchQuery).groupBy { it.membership }
if (results.isEmpty()) RoomMemberSearchResultState.NoResults if (results.isEmpty()) SearchBarResultState.NoResults()
else RoomMemberSearchResultState.Results( else SearchBarResultState.Results(
RoomMembers( RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(), invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(), joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),

View file

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

View file

@ -20,27 +20,20 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar 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.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.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text 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.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember 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 kotlinx.collections.immutable.ImmutableList
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomMemberListView( fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
@ -93,21 +86,20 @@ fun RoomMemberListView(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding), .padding(padding)
.consumeWindowInsets(padding),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Column { RoomMemberSearchBar(
RoomMemberSearchBar( query = state.searchQuery,
query = state.searchQuery, state = state.searchResults,
state = state.searchResults, active = state.isSearchActive,
active = state.isSearchActive, placeHolderTitle = stringResource(StringR.string.common_search_for_someone),
placeHolderTitle = stringResource(StringR.string.common_search_for_someone), onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, onUserSelected = ::onUserSelected,
onUserSelected = ::onUserSelected, modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth() )
)
}
if (!state.isSearchActive) { if (!state.isSearchActive) {
if (state.roomMembers is Async.Success) { if (state.roomMembers is Async.Success) {
@ -216,11 +208,10 @@ private fun RoomMemberListTopBar(
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RoomMemberSearchBar( private fun RoomMemberSearchBar(
query: String, query: String,
state: RoomMemberSearchResultState, state: SearchBarResultState<RoomMembers>,
active: Boolean, active: Boolean,
placeHolderTitle: String, placeHolderTitle: String,
onActiveChanged: (Boolean) -> Unit, onActiveChanged: (Boolean) -> Unit,
@ -228,72 +219,21 @@ private fun RoomMemberSearchBar(
onUserSelected: (RoomMember) -> Unit, onUserSelected: (RoomMember) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val focusManager = LocalFocusManager.current
if (!active) {
onTextChanged("")
focusManager.clearFocus()
}
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = onTextChanged, onQueryChange = onTextChanged,
onSearch = { focusManager.clearFocus() },
active = active, active = active,
onActiveChange = onActiveChanged, onActiveChange = onActiveChanged,
modifier = modifier modifier = modifier,
.padding(horizontal = if (!active) 16.dp else 0.dp), placeHolderTitle = placeHolderTitle,
placeholder = { resultState = state,
Text( resultHandler = { results ->
text = placeHolderTitle, RoomMemberList(
modifier = Modifier.alpha(0.4f), // FIXME align on Design system theme (removing alpha should be fine) 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.blockuser.BlockUserSection import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.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.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun RoomMemberDetailsView( fun RoomMemberDetailsView(
state: RoomMemberDetailsState, state: RoomMemberDetailsState,
@ -74,6 +76,7 @@ fun RoomMemberDetailsView(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
RoomMemberHeaderSection( 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.RoomMemberListDataSource
import io.element.android.features.roomdetails.impl.members.RoomMemberListEvents 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.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.aRoomMemberList
import io.element.android.features.roomdetails.impl.members.aVictor import io.element.android.features.roomdetails.impl.members.aVictor
import io.element.android.features.roomdetails.impl.members.aWalter import io.element.android.features.roomdetails.impl.members.aWalter
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
@ -49,7 +49,7 @@ class RoomMemberListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java) Truth.assertThat(initialState.roomMembers).isInstanceOf(Async.Loading::class.java)
Truth.assertThat(initialState.searchQuery).isEmpty() 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() Truth.assertThat(initialState.isSearchActive).isFalse()
val loadedState = awaitItem() val loadedState = awaitItem()
@ -89,7 +89,7 @@ class RoomMemberListPresenterTests {
val searchQueryUpdatedState = awaitItem() val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something") Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("something")
val searchSearchResultDelivered = awaitItem() 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() val searchQueryUpdatedState = awaitItem()
Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice") Truth.assertThat((searchQueryUpdatedState.searchQuery)).isEqualTo("Alice")
val searchSearchResultDelivered = awaitItem() val searchSearchResultDelivered = awaitItem()
Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(RoomMemberSearchResultState.Results::class.java) Truth.assertThat((searchSearchResultDelivered.searchResults)).isInstanceOf(SearchBarResultState.Results::class.java)
Truth.assertThat((searchSearchResultDelivered.searchResults as RoomMemberSearchResultState.Results).results.joined.first().displayName) Truth.assertThat((searchSearchResultDelivered.searchResults as SearchBarResultState.Results).results.joined.first().displayName)
.isEqualTo("Alice") .isEqualTo("Alice")
} }

View file

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

View file

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

@ -32,7 +32,7 @@ accompanist = "0.30.1"
test_core = "1.5.0" test_core = "1.5.0"
#other #other
coil = "2.3.0" coil = "2.4.0"
datetime = "0.4.0" datetime = "0.4.0"
serialization_json = "1.5.0" serialization_json = "1.5.0"
showkase = "1.0.0-beta18" showkase = "1.0.0-beta18"

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.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun PreferenceView( fun PreferenceView(
title: String, title: String,
@ -69,6 +72,7 @@ fun PreferenceView(
Column( Column(
modifier = Modifier modifier = Modifier
.padding(it) .padding(it)
.consumeWindowInsets(it)
.verticalScroll( .verticalScroll(
state = scrollState, state = scrollState,
) )

View file

@ -16,74 +16,216 @@
package io.element.android.libraries.designsystem.theme.components package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets 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.ExperimentalMaterial3Api
import androidx.compose.material3.SearchBarColors import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp 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.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.ui.strings.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchBar( fun <T> SearchBar(
query: String, query: String,
onQueryChange: (String) -> Unit, onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
active: Boolean, active: Boolean,
onActiveChange: (Boolean) -> Unit, onActiveChange: (Boolean) -> Unit,
placeHolderTitle: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null, resultState: SearchBarResultState<T> = SearchBarResultState.NotSearching(),
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape, shape: Shape = SearchBarDefaults.inputFieldShape,
colors: SearchBarColors = SearchBarDefaults.colors(),
tonalElevation: Dp = SearchBarDefaults.Elevation, tonalElevation: Dp = SearchBarDefaults.Elevation,
windowInsets: WindowInsets = SearchBarDefaults.windowInsets, windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 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( androidx.compose.material3.SearchBar(
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onSearch = onSearch, onSearch = { focusManager.clearFocus() },
active = active, active = active,
onActiveChange = onActiveChange, onActiveChange = onActiveChange,
modifier = modifier, modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp),
enabled = enabled, enabled = enabled,
placeholder = placeholder, placeholder = {
leadingIcon = leadingIcon, Text(
trailingIcon = trailingIcon, 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, shape = shape,
colors = colors, colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
tonalElevation = tonalElevation, tonalElevation = tonalElevation,
windowInsets = windowInsets, windowInsets = windowInsets,
interactionSource = interactionSource, 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) @Preview(group = PreviewGroup.Search)
@Composable @Composable
internal fun SearchBarPreview() = ElementThemedPreview { ContentToPreview() } internal fun SearchBarPreviewActiveWithQuery() = ElementThemedPreview {
ContentToPreview(
@OptIn(ExperimentalMaterial3Api::class) query = "search term",
@Composable active = true,
private fun ContentToPreview() { )
SearchBar( }
query = "Some text",
onQueryChange = {}, @Preview(group = PreviewGroup.Search)
onSearch = {}, @Composable
active = false, internal fun SearchBarPreviewActiveWithNoResults() = ElementThemedPreview {
onActiveChange = {}, ContentToPreview(
content = {}, 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.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose) implementation(libs.coil.compose)
ksp(libs.showkase.processor) 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.semantics.Role
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.matrix.api.user.MatrixUser 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 @Composable
fun CheckableMatrixUserRow( fun CheckableMatrixUserRow(
@ -40,18 +43,39 @@ fun CheckableMatrixUserRow(
avatarSize: AvatarSize = AvatarSize.MEDIUM, avatarSize: AvatarSize = AvatarSize.MEDIUM,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true, 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(role = Role.Checkbox) { onCheckedChange(!checked) }, .clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MatrixUserRow( UserRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
matrixUser = matrixUser, avatarData = avatarData,
avatarSize = avatarSize, name = name,
subtext = subtext,
) )
Checkbox( Checkbox(
@ -77,5 +101,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
Column { Column {
CheckableMatrixUserRow(checked = true, matrixUser) CheckableMatrixUserRow(checked = true, matrixUser)
CheckableMatrixUserRow(checked = false, 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar 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.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -46,6 +47,19 @@ fun MatrixUserRow(
matrixUser: MatrixUser, matrixUser: MatrixUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.MEDIUM, 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( Row(
modifier = modifier modifier = modifier
@ -54,9 +68,7 @@ fun MatrixUserRow(
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Avatar( Avatar(avatarData)
matrixUser.getAvatarData(size = avatarSize),
)
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp), .padding(start = 12.dp),
@ -65,15 +77,15 @@ fun MatrixUserRow(
Text( Text(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
text = matrixUser.getBestName(), text = name,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
// Id // Id
if (matrixUser.displayName.isNullOrEmpty().not()) { subtext?.let {
Text( Text(
text = matrixUser.userId.value, text = subtext,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
fontSize = 14.sp, fontSize = 14.sp,
maxLines = 1, maxLines = 1,

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.background
import androidx.compose.foundation.layout.Box 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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.user.MatrixUser 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.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName 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 @Composable
fun SelectedUser( fun SelectedUser(
@ -74,7 +73,7 @@ fun SelectedUser(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.action_remove), contentDescription = stringResource(id = StringR.string.action_remove),
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
) )
} }

View file

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

View file

@ -87,31 +87,29 @@
<string name="common_report_a_bug">"Report a bug"</string> <string name="common_report_a_bug">"Report a bug"</string>
<string name="common_report_submitted">"Report submitted"</string> <string name="common_report_submitted">"Report submitted"</string>
<string name="common_search_for_someone">"Search for someone"</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_security">"Security"</string>
<string name="common_select_your_server">"Select your server"</string> <string name="common_select_your_server">"Select your server"</string>
<string name="common_sending">"Sending…"</string> <string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string> <string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string> <string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</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_sticker">"Sticker"</string>
<string name="common_success">"Success"</string> <string name="common_success">"Success"</string>
<string name="common_suggestions">"Suggestions"</string> <string name="common_suggestions">"Suggestions"</string>
<string name="common_topic">"Topic"</string> <string name="common_topic">"Topic"</string>
<string name="common_unable_to_decrypt">"Unable to decrypt"</string> <string name="common_unable_to_decrypt">"Unable to decrypt"</string>
<string name="common_unable_to_invite_message">"We were unable to successfully send invites to one or more users."</string> <string name="common_unsupported_event">"Unsupported event"</string>
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string> <string name="common_username">"Username"</string>
<string name="common_unsupported_event">"Unsupported event"</string> <string name="common_verification_cancelled">"Verification cancelled"</string>
<string name="common_username">"Username"</string> <string name="common_verification_complete">"Verification complete"</string>
<string name="common_verification_cancelled">"Verification cancelled"</string> <string name="common_video">"Video"</string>
<string name="common_verification_complete">"Verification complete"</string> <string name="common_waiting">"Waiting…"</string>
<string name="common_video">"Video"</string> <string name="dialog_title_confirmation">"Confirmation"</string>
<string name="common_waiting">"Waiting…"</string> <string name="dialog_title_warning">"Warning"</string>
<string name="dialog_title_confirmation">"Confirmation"</string> <string name="emoji_picker_category_activity">"Activities"</string>
<string name="dialog_title_warning">"Warning"</string> <string name="emoji_picker_category_flags">"Flags"</string>
<string name="emoji_picker_category_activity">"Activities"</string>
<string name="emoji_picker_category_flags">"Flags"</string>
<string name="emoji_picker_category_foods">"Food &amp; Drink"</string> <string name="emoji_picker_category_foods">"Food &amp; Drink"</string>
<string name="emoji_picker_category_nature">"Animals &amp; Nature"</string> <string name="emoji_picker_category_nature">"Animals &amp; Nature"</string>
<string name="emoji_picker_category_objects">"Objects"</string> <string name="emoji_picker_category_objects">"Objects"</string>

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License. * 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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser

View file

@ -14,22 +14,12 @@
* limitations under the License. * 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 io.element.android.libraries.matrix.api.user.MatrixUser
import dagger.Binds import kotlinx.coroutines.flow.Flow
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 interface UserRepository {
@ContributesTo(SessionScope::class)
interface CreateRoomModule {
@Binds
@Named("AllUsers")
fun bindAllUserListDataSource(dataSource: AllMatrixUsersDataSource): UserListDataSource
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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -15,13 +15,12 @@
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
} }
android { android {
namespace = "io.element.android.features.userlist.impl" namespace = "io.element.android.libraries.usersearch.impl"
} }
anvil { anvil {
@ -29,28 +28,18 @@ anvil {
} }
dependencies { dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.di)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.elementresources) api(projects.libraries.usersearch.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
api(projects.features.userlist.api)
ksp(libs.showkase.processor)
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.coroutines.core)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.mockk)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.userlist.test) testImplementation(projects.libraries.usersearch.test)
androidTestImplementation(libs.test.junitext)
} }

View file

@ -14,15 +14,18 @@
* limitations under the License. * 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.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import javax.inject.Inject import javax.inject.Inject
class AllMatrixUsersDataSource @Inject constructor( @ContributesBinding(SessionScope::class)
class MatrixUserListDataSource @Inject constructor(
private val client: MatrixClient private val client: MatrixClient
) : UserListDataSource { ) : UserListDataSource {
override suspend fun search(query: String): List<MatrixUser> { 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. * 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 com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.core.UserId 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 kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
internal class AllMatrixUsersDataSourceTest { internal class MatrixUserListDataSourceTest {
@Test @Test
fun `search - returns users on success`() = runTest { fun `search - returns users on success`() = runTest {
@ -37,15 +37,21 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test", searchTerm = "test",
result = Result.success( result = Result.success(
MatrixSearchUserResults( MatrixSearchUserResults(
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)), results = listOf(
aMatrixUserProfile(),
aMatrixUserProfile(userId = A_USER_ID_2)
),
limited = false limited = false
) )
) )
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test") 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 @Test
@ -55,7 +61,7 @@ internal class AllMatrixUsersDataSourceTest {
searchTerm = "test", searchTerm = "test",
result = Result.failure(Throwable("Ruhroh")) result = Result.failure(Throwable("Ruhroh"))
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val results = dataSource.search("test") val results = dataSource.search("test")
Truth.assertThat(results).isEmpty() Truth.assertThat(results).isEmpty()
@ -68,7 +74,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID, userId = A_USER_ID,
result = Result.success(aMatrixUserProfile()) result = Result.success(aMatrixUserProfile())
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID) val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isEqualTo(aMatrixUserProfile()) Truth.assertThat(result).isEqualTo(aMatrixUserProfile())
@ -81,7 +87,7 @@ internal class AllMatrixUsersDataSourceTest {
userId = A_USER_ID, userId = A_USER_ID,
result = Result.failure(Throwable("Ruhroh")) result = Result.failure(Throwable("Ruhroh"))
) )
val dataSource = AllMatrixUsersDataSource(matrixClient) val dataSource = MatrixUserListDataSource(matrixClient)
val result = dataSource.getProfile(A_USER_ID) val result = dataSource.getProfile(A_USER_ID)
Truth.assertThat(result).isNull() 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-library")
} }
android { android {
namespace = "io.element.android.features.userlist.test" namespace = "io.element.android.libraries.usersearch"
} }
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api) api(projects.libraries.usersearch.api)
api(libs.coroutines.core)
} }

View file

@ -14,11 +14,11 @@
* limitations under the License. * 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.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
class FakeUserListDataSource : 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:statemachine"))
implementation(project(":libraries:mediapickers:impl")) implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl")) implementation(project(":libraries:mediaupload:impl"))
implementation(project(":libraries:usersearch:impl"))
} }
fun DependencyHandlerScope.allServicesImpl() { fun DependencyHandlerScope.allServicesImpl() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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