Search for users to start a new DM. (#376)
Search for users to start a new DM. Hooks up the create room UI to the matrix client to get search results. Searches are debounced for 500ms and only executed when 3 or more characters are entered. Wrap the result state so we can distinguish between "no results because we haven't searched yet" and "no results because the API returned nothing", and add a "No results found" message in the UI for the latter case. Closes #95
This commit is contained in:
parent
c411faa93d
commit
99f571b4eb
48 changed files with 432 additions and 71 deletions
1
changelog.d/95.feature
Normal file
1
changelog.d/95.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
[Create and join rooms] Search for users to start a DM
|
||||
|
|
@ -426,6 +426,7 @@ Rageshake can be very useful to get logs from a release version of the applicati
|
|||
- When this is possible, prefer using `sealed interface` instead of `sealed class`;
|
||||
- When writing temporary code, using the string "DO NOT COMMIT" in a comment can help to avoid committing things by mistake. If committed and pushed, the CI
|
||||
will detect this String and will warn the user about it. (TODO Not supported yet!)
|
||||
- Very occasionally the gradle cache misbehaves and causes problems with Dagger. Try building with `--no-build-cache` if Dagger isn't behaving how you expect.
|
||||
|
||||
## Happy coding!
|
||||
|
||||
|
|
|
|||
|
|
@ -17,17 +17,37 @@
|
|||
package io.element.android.features.createroom.impl
|
||||
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import javax.inject.Inject
|
||||
|
||||
// TODO this is empty as we currently don't have an endpoint to perform user search
|
||||
class AllMatrixUsersDataSource @Inject constructor() : UserListDataSource {
|
||||
class AllMatrixUsersDataSource @Inject constructor(
|
||||
private val client: MatrixClient
|
||||
) : UserListDataSource {
|
||||
override suspend fun search(query: String): List<MatrixUser> {
|
||||
return emptyList()
|
||||
val res = client.searchUsers(query, MAX_SEARCH_RESULTS)
|
||||
return res.getOrNull()?.results?.map(::toMatrixUser).orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getProfile(userId: UserId): MatrixUser? {
|
||||
// TODO hook up to matrix client
|
||||
return null
|
||||
}
|
||||
|
||||
private fun toMatrixUser(matrixUserProfile: MatrixUserProfile) = MatrixUser(
|
||||
id = matrixUserProfile.userId,
|
||||
username = matrixUserProfile.displayName,
|
||||
avatarData = AvatarData(
|
||||
id = matrixUserProfile.userId.value,
|
||||
name = matrixUserProfile.displayName,
|
||||
url = matrixUserProfile.avatarUrl,
|
||||
)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MAX_SEARCH_RESULTS = 5L
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.addpeople
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.userlist.api.SelectionMode
|
||||
import io.element.android.features.userlist.api.UserListState
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.api.aListOfSelectedUsers
|
||||
import io.element.android.features.userlist.api.aUserListState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
|
|
@ -29,13 +30,13 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
|||
get() = sequenceOf(
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
searchResults = aMatrixUserList().toImmutableList(),
|
||||
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
isSearchActive = false,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
aUserListState().copy(
|
||||
searchResults = aMatrixUserList().toImmutableList(),
|
||||
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
isSearchActive = true,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ import dagger.Binds
|
|||
import dagger.Module
|
||||
import io.element.android.features.createroom.impl.AllMatrixUsersDataSource
|
||||
import io.element.android.features.userlist.api.UserListDataSource
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import javax.inject.Named
|
||||
|
||||
@Module
|
||||
@ContributesTo(AppScope::class)
|
||||
@ContributesTo(SessionScope::class)
|
||||
interface CreateRoomModule {
|
||||
|
||||
@Binds
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ class CreateRoomRootPresenter @Inject constructor(
|
|||
|
||||
private val presenter by lazy {
|
||||
presenterFactory.create(
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single),
|
||||
UserListPresenterArgs(
|
||||
selectionMode = SelectionMode.Single,
|
||||
minimumSearchLength = 3,
|
||||
searchDebouncePeriodMillis = 500,
|
||||
),
|
||||
userListDataSource,
|
||||
userListDataStore,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.createroom.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.api.aUserListState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
|
@ -31,7 +32,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
searchQuery = it.id.value,
|
||||
searchResults = persistentListOf(it),
|
||||
searchResults = UserSearchResultState.Results(persistentListOf(it)),
|
||||
selectedUsers = persistentListOf(it),
|
||||
isSearchActive = true,
|
||||
)
|
||||
|
|
@ -42,7 +43,7 @@ open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRoot
|
|||
userListState = aMatrixUser().let {
|
||||
aUserListState().copy(
|
||||
searchQuery = it.id.value,
|
||||
searchResults = persistentListOf(it),
|
||||
searchResults = UserSearchResultState.Results(persistentListOf(it)),
|
||||
selectedUsers = persistentListOf(it),
|
||||
isSearchActive = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.createroom.impl
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal class AllMatrixUsersDataSourceTest {
|
||||
|
||||
@Test
|
||||
fun `search - returns users on success`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
matrixClient.givenSearchUsersResult(
|
||||
searchTerm = "test",
|
||||
result = Result.success(
|
||||
MatrixSearchUserResults(
|
||||
results = listOf(aMatrixUserProfile(), aMatrixUserProfile(userId = A_USER_ID_2)),
|
||||
limited = false
|
||||
)
|
||||
)
|
||||
)
|
||||
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||
|
||||
val results = dataSource.search("test")
|
||||
Truth.assertThat(results).containsExactly(
|
||||
MatrixUser(
|
||||
id = A_USER_ID,
|
||||
username = A_USER_NAME,
|
||||
avatarData = AvatarData(id = A_USER_ID.value, name = A_USER_NAME, url = AN_AVATAR_URL)
|
||||
),
|
||||
MatrixUser(
|
||||
id = A_USER_ID_2,
|
||||
username = A_USER_NAME,
|
||||
avatarData = AvatarData(id = A_USER_ID_2.value, name = A_USER_NAME, url = AN_AVATAR_URL)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search - returns empty list on error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
matrixClient.givenSearchUsersResult(
|
||||
searchTerm = "test",
|
||||
result = Result.failure(Throwable("Ruhroh"))
|
||||
)
|
||||
val dataSource = AllMatrixUsersDataSource(matrixClient)
|
||||
|
||||
val results = dataSource.search("test")
|
||||
Truth.assertThat(results).isEmpty()
|
||||
}
|
||||
|
||||
private fun aMatrixUserProfile(
|
||||
userId: UserId = A_USER_ID,
|
||||
displayName: String = A_USER_NAME,
|
||||
avatarUrl: String = AN_AVATAR_URL
|
||||
) = MatrixUserProfile(userId, displayName, avatarUrl)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.roomdetails.impl.members
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.api.aUserListState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
|
@ -33,7 +34,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
|
|||
}
|
||||
|
||||
internal fun aRoomMemberListState(
|
||||
searchResults: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
searchResults: UserSearchResultState = UserSearchResultState.NotSearching,
|
||||
allUsers: Async<ImmutableList<MatrixUser>> = Async.Uninitialized,
|
||||
) =
|
||||
RoomMemberListState(
|
||||
|
|
|
|||
|
|
@ -26,15 +26,14 @@ import io.element.android.features.userlist.api.UserListDataSource
|
|||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
import io.element.android.features.userlist.api.UserListPresenter
|
||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.impl.DefaultUserListPresenter
|
||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.internal.toImmutableList
|
||||
import org.junit.Test
|
||||
|
|
@ -72,7 +71,7 @@ class RoomMemberListPresenterTests {
|
|||
val initialState = awaitItem()
|
||||
Truth.assertThat(initialState.allUsers).isInstanceOf(Async.Loading::class.java)
|
||||
Truth.assertThat(initialState.userListState.isSearchActive).isFalse()
|
||||
Truth.assertThat(initialState.userListState.searchResults).isEmpty()
|
||||
Truth.assertThat(initialState.userListState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
Truth.assertThat(initialState.userListState.selectionMode).isEqualTo(SelectionMode.Single)
|
||||
|
||||
val loadedState = awaitItem()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
|
|
@ -27,4 +28,5 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ package io.element.android.features.userlist.api
|
|||
|
||||
data class UserListPresenterArgs(
|
||||
val selectionMode: SelectionMode,
|
||||
val minimumSearchLength: Int = 1,
|
||||
val searchDebouncePeriodMillis: Long = 0,
|
||||
)
|
||||
|
||||
enum class SelectionMode {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
|
||||
data class UserListState(
|
||||
val searchQuery: String,
|
||||
val searchResults: ImmutableList<MatrixUser>,
|
||||
val searchResults: UserSearchResultState,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val isSearchActive: Boolean,
|
||||
val selectionMode: SelectionMode,
|
||||
|
|
@ -29,3 +29,14 @@ data class UserListState(
|
|||
) {
|
||||
val isMultiSelectionEnabled = selectionMode == SelectionMode.Multiple
|
||||
}
|
||||
|
||||
sealed interface UserSearchResultState {
|
||||
/** No search results are available yet (e.g. because the user hasn't entered a (long enough) search term). */
|
||||
object NotSearching : UserSearchResultState
|
||||
|
||||
/** The search has completed, but no results were found. */
|
||||
object NoResults : UserSearchResultState
|
||||
|
||||
/** The search has completed, and some matching users were found. */
|
||||
data class Results(val results: ImmutableList<MatrixUser>) : UserSearchResultState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,22 +37,27 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
|||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = aMatrixUserList().toImmutableList(),
|
||||
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = aMatrixUserList().toImmutableList(),
|
||||
)
|
||||
searchResults = UserSearchResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "something-with-no-results",
|
||||
searchResults = UserSearchResultState.NoResults
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aUserListState() = UserListState(
|
||||
isSearchActive = false,
|
||||
searchQuery = "",
|
||||
searchResults = persistentListOf(),
|
||||
searchResults = UserSearchResultState.NotSearching,
|
||||
selectedUsers = persistentListOf(),
|
||||
selectionMode = SelectionMode.Single,
|
||||
eventSink = {}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,17 @@
|
|||
package io.element.android.features.userlist.api.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -32,7 +35,9 @@ import androidx.compose.ui.draw.alpha
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
|
|
@ -46,7 +51,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||
@Composable
|
||||
fun SearchUserBar(
|
||||
query: String,
|
||||
results: ImmutableList<MatrixUser>,
|
||||
state: UserSearchResultState,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
active: Boolean,
|
||||
isMultiSelectionEnabled: Boolean,
|
||||
|
|
@ -91,6 +96,7 @@ fun SearchUserBar(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
!active -> {
|
||||
{
|
||||
Icon(
|
||||
|
|
@ -100,6 +106,7 @@ fun SearchUserBar(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> null
|
||||
},
|
||||
colors = if (!active) SearchBarDefaults.colors() else SearchBarDefaults.colors(containerColor = Color.Transparent),
|
||||
|
|
@ -113,31 +120,43 @@ fun SearchUserBar(
|
|||
)
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
if (isMultiSelectionEnabled) {
|
||||
items(results) { matrixUser ->
|
||||
SearchMultipleUsersResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
onUserSelected(matrixUser)
|
||||
} else {
|
||||
onUserDeselected(matrixUser)
|
||||
|
||||
if (state is UserSearchResultState.Results) {
|
||||
LazyColumn {
|
||||
if (isMultiSelectionEnabled) {
|
||||
items(state.results) { matrixUser ->
|
||||
SearchMultipleUsersResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
isUserSelected = selectedUsers.find { it.id == matrixUser.id } != null,
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
onUserSelected(matrixUser)
|
||||
} else {
|
||||
onUserDeselected(matrixUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(results) { matrixUser ->
|
||||
SearchSingleUserResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
onClick = { onUserSelected(matrixUser) }
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(state.results) { matrixUser ->
|
||||
SearchSingleUserResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
matrixUser = matrixUser,
|
||||
onClick = { onUserSelected(matrixUser) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (state is UserSearchResultState.NoResults) {
|
||||
Spacer(Modifier.size(80.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.common_no_results),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ fun UserListView(
|
|||
SearchUserBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
query = state.searchQuery,
|
||||
results = state.searchResults,
|
||||
state = state.searchResults,
|
||||
selectedUsers = state.selectedUsers,
|
||||
active = state.isSearchActive,
|
||||
isMultiSelectionEnabled = state.isMultiSelectionEnabled,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package io.element.android.features.userlist.impl
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -35,13 +34,14 @@ import io.element.android.features.userlist.api.UserListEvents
|
|||
import io.element.android.features.userlist.api.UserListPresenter
|
||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||
import io.element.android.features.userlist.api.UserListState
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.MatrixPatterns
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class DefaultUserListPresenter @AssistedInject constructor(
|
||||
@Assisted val args: UserListPresenterArgs,
|
||||
|
|
@ -62,45 +62,47 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
@Composable
|
||||
override fun present(): UserListState {
|
||||
var isSearchActive by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedUsers = userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
val searchResults: MutableState<ImmutableList<MatrixUser>> = remember {
|
||||
mutableStateOf(persistentListOf())
|
||||
}
|
||||
|
||||
fun handleEvents(event: UserListEvents) {
|
||||
when (event) {
|
||||
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
|
||||
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
|
||||
}
|
||||
var searchResults: UserSearchResultState by remember {
|
||||
mutableStateOf(UserSearchResultState.NotSearching)
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
// Clear the search results before performing the search, manually add a fake result with the matrixId, if any
|
||||
searchResults.value = if (MatrixPatterns.isUserId(searchQuery)) {
|
||||
persistentListOf(MatrixUser(UserId(searchQuery)))
|
||||
searchResults = if (MatrixPatterns.isUserId(searchQuery)) {
|
||||
UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(searchQuery))))
|
||||
} else {
|
||||
persistentListOf()
|
||||
UserSearchResultState.NotSearching
|
||||
}
|
||||
|
||||
// Debounce
|
||||
delay(args.searchDebouncePeriodMillis)
|
||||
|
||||
// Perform the search asynchronously
|
||||
if (searchQuery.isNotEmpty()) {
|
||||
searchResults.value = performSearch(searchQuery)
|
||||
if (searchQuery.length >= args.minimumSearchLength) {
|
||||
searchResults = performSearch(searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return UserListState(
|
||||
searchQuery = searchQuery,
|
||||
searchResults = searchResults.value,
|
||||
selectedUsers = selectedUsers.value.toImmutableList(),
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers.toImmutableList(),
|
||||
isSearchActive = isSearchActive,
|
||||
selectionMode = args.selectionMode,
|
||||
eventSink = ::handleEvents,
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is UserListEvents.OnSearchActiveChanged -> isSearchActive = event.active
|
||||
is UserListEvents.UpdateSearchQuery -> searchQuery = event.query
|
||||
is UserListEvents.AddToSelection -> userListDataStore.selectUser(event.matrixUser)
|
||||
is UserListEvents.RemoveFromSelection -> userListDataStore.removeUserFromSelection(event.matrixUser)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun performSearch(query: String): ImmutableList<MatrixUser> {
|
||||
private suspend fun performSearch(query: String): UserSearchResultState {
|
||||
val isMatrixId = MatrixPatterns.isUserId(query)
|
||||
val results = userListDataSource.search(query).toMutableList()
|
||||
if (isMatrixId && results.none { it.id.value == query }) {
|
||||
|
|
@ -108,6 +110,6 @@ class DefaultUserListPresenter @AssistedInject constructor(
|
|||
val profile = getProfileResult ?: MatrixUser(UserId(query))
|
||||
results.add(0, profile)
|
||||
}
|
||||
return results.toImmutableList()
|
||||
return if (results.isEmpty()) UserSearchResultState.NoResults else UserSearchResultState.Results(results.toImmutableList())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,12 +25,14 @@ import io.element.android.features.userlist.api.SelectionMode
|
|||
import io.element.android.features.userlist.api.UserListDataStore
|
||||
import io.element.android.features.userlist.api.UserListEvents
|
||||
import io.element.android.features.userlist.api.UserListPresenterArgs
|
||||
import io.element.android.features.userlist.api.UserSearchResultState
|
||||
import io.element.android.features.userlist.test.FakeUserListDataSource
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import io.mockk.coJustRun
|
||||
import io.mockk.mockkConstructor
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
|
@ -55,7 +57,7 @@ class DefaultUserListPresenterTests {
|
|||
assertThat(initialState.isMultiSelectionEnabled).isFalse()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.searchResults).isEmpty()
|
||||
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +76,7 @@ class DefaultUserListPresenterTests {
|
|||
assertThat(initialState.isMultiSelectionEnabled).isTrue()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
assertThat(initialState.selectedUsers).isEmpty()
|
||||
assertThat(initialState.searchResults).isEmpty()
|
||||
assertThat(initialState.searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,18 +98,42 @@ class DefaultUserListPresenterTests {
|
|||
val matrixIdQuery = "@name:matrix.org"
|
||||
initialState.eventSink(UserListEvents.UpdateSearchQuery(matrixIdQuery))
|
||||
assertThat(awaitItem().searchQuery).isEqualTo(matrixIdQuery)
|
||||
assertThat(awaitItem().searchResults).containsExactly(MatrixUser(UserId(matrixIdQuery)))
|
||||
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(MatrixUser(UserId(matrixIdQuery)))))
|
||||
|
||||
val notMatrixIdQuery = "name"
|
||||
initialState.eventSink(UserListEvents.UpdateSearchQuery(notMatrixIdQuery))
|
||||
assertThat(awaitItem().searchQuery).isEqualTo(notMatrixIdQuery)
|
||||
assertThat(awaitItem().searchResults).isEmpty()
|
||||
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NoResults)
|
||||
|
||||
initialState.eventSink(UserListEvents.OnSearchActiveChanged(false))
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - searches when minimum length exceeded`() = runTest {
|
||||
val presenter = DefaultUserListPresenter(
|
||||
UserListPresenterArgs(selectionMode = SelectionMode.Single, minimumSearchLength = 3),
|
||||
userListDataSource,
|
||||
UserListDataStore(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
// When the search term is too short, nothing happens
|
||||
initialState.eventSink(UserListEvents.UpdateSearchQuery("al"))
|
||||
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
|
||||
// When it reaches the minimum length, a search is performed asynchronously
|
||||
userListDataSource.givenSearchResult(listOf(aMatrixUser()))
|
||||
initialState.eventSink(UserListEvents.UpdateSearchQuery("alice"))
|
||||
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.NotSearching)
|
||||
assertThat(awaitItem().searchResults).isEqualTo(UserSearchResultState.Results(persistentListOf(aMatrixUser())))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - select a user`() = runTest {
|
||||
mockkConstructor(LazyListState::class)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import java.io.Closeable
|
||||
|
||||
|
|
@ -58,4 +59,6 @@ interface MatrixClient : Closeable {
|
|||
fun onSlidingSyncUpdate()
|
||||
|
||||
fun roomMembershipObserver(): RoomMembershipObserver
|
||||
|
||||
suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.usersearch
|
||||
|
||||
data class MatrixSearchUserResults(
|
||||
val results: List<MatrixUserProfile>,
|
||||
val limited: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.usersearch
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
data class MatrixUserProfile(
|
||||
val userId: UserId,
|
||||
val displayName: String?,
|
||||
val avatarUrl: String?
|
||||
)
|
||||
|
|
@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
|
||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||
|
|
@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.impl.pushers.RustPushersService
|
|||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
|
||||
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
|
||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
|
@ -362,6 +364,13 @@ class RustMatrixClient constructor(
|
|||
|
||||
override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
|
||||
withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
UserSearchResultMapper.map(client.searchUsers(searchTerm, limit.toULong()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.deleteSessionDirectory(userID: String): Boolean {
|
||||
// Rust sanitises the user ID replacing invalid characters with an _
|
||||
val sanitisedUserID = userID.replace(":", "_")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.usersearch
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixUserProfile
|
||||
import org.matrix.rustcomponents.sdk.SearchUsersResults
|
||||
import org.matrix.rustcomponents.sdk.UserProfile
|
||||
|
||||
object UserSearchResultMapper {
|
||||
|
||||
fun map(result: SearchUsersResults): MatrixSearchUserResults {
|
||||
return MatrixSearchUserResults(
|
||||
results = result.results.map(::mapUserProfile),
|
||||
limited = result.limited,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapUserProfile(userProfile: UserProfile): MatrixUserProfile {
|
||||
return MatrixUserProfile(
|
||||
userId = UserId(userProfile.userId),
|
||||
displayName = userProfile.displayName,
|
||||
avatarUrl = userProfile.avatarUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
|
|||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.usersearch.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
|
||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||
|
|
@ -55,6 +56,7 @@ class FakeMatrixClient(
|
|||
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
|
||||
private var logoutFailure: Throwable? = null
|
||||
private val getRoomResults = mutableMapOf<RoomId, MatrixRoom>()
|
||||
private val searchUserResults = mutableMapOf<String, Result<MatrixSearchUserResults>>()
|
||||
|
||||
override fun getRoom(roomId: RoomId): MatrixRoom? {
|
||||
return getRoomResults[roomId]
|
||||
|
|
@ -126,6 +128,10 @@ class FakeMatrixClient(
|
|||
return RoomMembershipObserver()
|
||||
}
|
||||
|
||||
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> {
|
||||
return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm"))
|
||||
}
|
||||
|
||||
// Mocks
|
||||
|
||||
fun givenLogoutError(failure: Throwable?) {
|
||||
|
|
@ -159,4 +165,8 @@ class FakeMatrixClient(
|
|||
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) {
|
||||
getRoomResults[roomId] = result
|
||||
}
|
||||
|
||||
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {
|
||||
searchUserResults[searchTerm] = result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4bd925ebe0257b400ed0b1cb956848622d1b42d8afcc71f915522c4878aaf94b
|
||||
size 23447
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12b569ad80bb4a4df89d70d9ae4a92da0d9e96deb477e1e437efbef5ec4a1fdd
|
||||
size 22302
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:91de9d54216aac2bf2c4721e5e8ff5e74be43105846bdac16230cb7bea7427af
|
||||
size 13777
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:38fbef57e6fb2860d05ab2b864bab16a30ebc98199eb18b19e422f946c52e163
|
||||
size 13096
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b357bc650466f045ff7a3d601b0a716f81af9fc55cd4f082a231747905e6860
|
||||
size 13146
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:de04bcf3f3d053024f7f5e9e11da95b7d609cf3c1982db70d1ef734b0f21d729
|
||||
size 12577
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ffcc7edc0898f3afccf77679a477e76f9f9aef5c4f0309518b3025ed6ce17f96
|
||||
size 32696
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc5d0c10593401fbb674c2a27333e546db9b0c11aca9ae280a721aa0e2d16765
|
||||
size 31793
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02d8edfee13ad4ca97dde238939b6166cccc28550d77c7b924c6aebd6e8d6a07
|
||||
size 9916
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a212fb83b307da9ebd0681492286d7f19520c8140629f0a21bad0c43e3954d6
|
||||
size 36910
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:89ea65099fb4981bbeb24e49afb7400b0e8da79e3b783e198519ba1e970404a8
|
||||
size 8317
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
|
||||
size 7733
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9a8ba207cf61c56b64c6855d04c8a30def0b3daf325adf57edd45b40981ed745
|
||||
size 7733
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a57daa1da9d07e6c7d497c63eb0c9fda36f48ea28f4592eef62774034b86b5e3
|
||||
size 87856
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0071d13454a9e8d75fc693d2f13a65c4d61910425217757804d92c086d56792f
|
||||
size 102885
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d5b879b74654fdad0638f432a71adfc9186fbe00dafd5d963a3affb33ec8c5c8
|
||||
size 12841
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2fbafefd9aa66ac411836715ef57f39e05e4eeeb0f4601b8a1706d9a3a487527
|
||||
size 9692
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:18d25ee10a5deb2e7a81aadd65dad542ed1a40889d77e375412c66c30a834492
|
||||
size 34260
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b8de2f28cf9918a7eddcc1311c34ba0376242a4708724b57689df000b7480524
|
||||
size 8197
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
|
||||
size 7514
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:687133e4729ea42382ac0b76af6ceaf8b20cbc65923412fb97b077d2475c70f7
|
||||
size 7514
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a708d195ffce2d542c494c6949456a1d341dff5c51f742e04212b72863c91988
|
||||
size 84136
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ff83f67a9fbcc5066547224c2f26d94c33bd0b60563acc062fde399ef9d681e1
|
||||
size 97631
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:534cba0c13aa52b3557ab8df854c521dd13b82e5a1550d73abcef12925e389da
|
||||
size 11878
|
||||
Loading…
Add table
Add a link
Reference in a new issue