Display a notice if Matrix ID isn't resolved (#461)

Display a notice if Matrix ID isn't resolved

If we can't get the profile of a user after an mxid was
searched for, show a warning under their ID to say the
invite probably won't be delivered.

Closes #424
This commit is contained in:
Chris Smith 2023-06-01 09:40:45 +01:00 committed by GitHub
parent 4dbeaa3390
commit 473bfd1e23
89 changed files with 503 additions and 216 deletions

1
changelog.d/424.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] Show a notice for MXIDs that don't resolve when searching for users to invite

View file

@ -22,40 +22,49 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchMultipleUsersResultItem(
matrixUser: MatrixUser,
searchResult: UserSearchResult,
isUserSelected: Boolean,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
) {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
if (searchResult.isUnresolved) {
CheckableUnresolvedUserRow(
checked = isUserSelected,
modifier = modifier,
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
id = searchResult.matrixUser.userId.value,
onCheckedChange = onCheckedChange,
)
} else {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
}
}
@Preview
@Composable
internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true)
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = false)
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = true)
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = false)
SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true), isUserSelected = true)
}
}

View file

@ -17,39 +17,48 @@
package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.UnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable
fun SearchSingleUserResultItem(
matrixUser: MatrixUser,
searchResult: UserSearchResult,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
)
if (searchResult.isUnresolved) {
UnresolvedUserRow(
modifier = modifier.clickable(onClick = onClick),
avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
id = searchResult.matrixUser.userId.value,
)
} else {
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
)
}
}
@Preview
@Composable
internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() }
@Composable
private fun ContentToPreview() {
SearchSingleUserResultItem(matrixUser = aMatrixUser())
Column {
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false))
SearchSingleUserResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = true))
}
}

View file

@ -29,12 +29,13 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
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 io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@Composable
fun SearchUserBar(
query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>,
state: SearchBarResultState<ImmutableList<UserSearchResult>>,
selectedUsers: ImmutableList<MatrixUser>,
active: Boolean,
isMultiSelectionEnabled: Boolean,
@ -68,26 +69,26 @@ fun SearchUserBar(
resultHandler = { users ->
LazyColumn {
if (isMultiSelectionEnabled) {
items(users) { matrixUser ->
items(users) { searchResult ->
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null,
searchResult = searchResult,
isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
onCheckedChange = { checked ->
if (checked) {
onUserSelected(matrixUser)
onUserSelected(searchResult.matrixUser)
} else {
onUserDeselected(matrixUser)
onUserDeselected(searchResult.matrixUser)
}
}
)
}
} else {
items(users) { matrixUser ->
items(users) { searchResult ->
SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser,
onClick = { onUserSelected(matrixUser) }
searchResult = searchResult,
onClick = { onUserSelected(searchResult.matrixUser) }
)
}
}

View file

@ -30,8 +30,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -56,7 +56,7 @@ class DefaultUserListPresenter @AssistedInject constructor(
var isSearchActive by rememberSaveable { mutableStateOf(false) }
val selectedUsers by userListDataStore.selectedUsers().collectAsState(emptyList())
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults: SearchBarResultState<ImmutableList<MatrixUser>> by remember {
var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.NotSearching())
}

View file

@ -18,11 +18,12 @@ 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.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
data class UserListState(
val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>,
val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean,
val selectionMode: SelectionMode,

View file

@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.userlist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -38,14 +39,14 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
),
aUserListState().copy(
isSearchActive = true,

View file

@ -5,4 +5,4 @@
<string name="screen_create_room_add_people_title">"Añadir personas"</string>
<string name="screen_start_chat_error_starting_chat">"Se ha producido un error al intentar iniciar un chat"</string>
<string name="screen_create_room_title">"Crear una sala"</string>
</resources>
</resources>

View file

@ -5,4 +5,4 @@
<string name="screen_create_room_add_people_title">"Aggiungi persone"</string>
<string name="screen_start_chat_error_starting_chat">"Si è verificato un errore durante il tentativo di avviare una chat"</string>
<string name="screen_create_room_title">"Crea una stanza"</string>
</resources>
</resources>

View file

@ -14,4 +14,4 @@
<string name="screen_create_room_topic_placeholder">"Despre ce este această cameră?"</string>
<string name="screen_start_chat_error_starting_chat">"A apărut o eroare la încercarea începerii conversației"</string>
<string name="screen_create_room_title">"Creați o cameră"</string>
</resources>
</resources>

View file

@ -23,6 +23,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
@ -130,18 +131,18 @@ class DefaultUserListPresenterTests {
skipItems(2)
// When the user repository emits a result, it's copied to the state
userRepository.emitResult(listOf(aMatrixUser()))
userRepository.emitResult(listOf(UserSearchResult(aMatrixUser())))
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
persistentListOf(aMatrixUser())
persistentListOf(UserSearchResult(aMatrixUser()))
)
)
// When the user repository emits another result, it replaces the previous value
userRepository.emitResult(aMatrixUserList())
userRepository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results(
aMatrixUserList()
aMatrixUserList().map { UserSearchResult(it) }
)
)
}

View file

@ -45,9 +45,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.roomdetails.impl.members.details.RoomMemberHeaderSection
import io.element.android.features.roomdetails.impl.members.details.RoomMemberMainActionsSection
import io.element.android.libraries.architecture.isLoading
@ -67,6 +67,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.R as StringR
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -185,7 +186,7 @@ internal fun RoomHeaderSection(
@Composable
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) {
PreferenceCategory(title = stringResource(R.string.screen_room_details_topic_title), modifier = modifier) {
PreferenceCategory(title = stringResource(StringR.string.common_topic), modifier = modifier) {
Text(
roomTopic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),

View file

@ -126,15 +126,16 @@ class RoomInviteMembersPresenter @Inject constructor(
userRepository.search(searchQuery).collect {
searchResults.value = when {
it.isEmpty() -> SearchBarResultState.NoResults()
else -> SearchBarResultState.Results(it.map { user ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership
else -> SearchBarResultState.Results(it.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN
val isInvited = existingMembership == RoomMembershipState.INVITE
InvitableUser(
matrixUser = user,
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited,
matrixUser = result.matrixUser,
isSelected = selectedUsers.value.contains(result.matrixUser) || isJoined || isInvited,
isAlreadyJoined = isJoined,
isAlreadyInvited = isInvited,
isUnresolved = result.isUnresolved,
)
}.toImmutableList())
}

View file

@ -35,4 +35,5 @@ data class InvitableUser(
val isSelected: Boolean = false,
val isAlreadyJoined: Boolean = false,
val isAlreadyInvited: Boolean = false,
val isUnresolved: Boolean = false,
)

View file

@ -48,5 +48,19 @@ internal class RoomInviteMembersStateProvider : PreviewParameterProvider<RoomInv
)
)
),
RoomInviteMembersState(
isSearchActive = true,
canInvite = true,
searchQuery = "@alice:server.org",
selectedUsers = persistentListOf(
aMatrixUser("@carol:server.org", "Carol")
),
searchResults = SearchBarResultState.Results(
persistentListOf(
InvitableUser(aMatrixUser("@alice:server.org"), isUnresolved = true),
InvitableUser(aMatrixUser("@bob:server.org", "Bob")),
)
)
),
)
}

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUnresolvedUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -180,22 +181,32 @@ private fun RoomInviteMembersSearchBar(
LazyColumn {
items(results) { invitableUser ->
CheckableUserRow(
checked = invitableUser.isSelected,
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
name = invitableUser.matrixUser.getBestName(),
subtext = when {
// If they're already invited or joined we show that information
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
// Otherwise show the ID, unless that's already used for their name
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
else -> null
},
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth()
)
if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) {
CheckableUnresolvedUserRow(
checked = invitableUser.isSelected,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
id = invitableUser.matrixUser.userId.value,
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth()
)
} else {
CheckableUserRow(
checked = invitableUser.isSelected,
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
name = invitableUser.matrixUser.getBestName(),
subtext = when {
// If they're already invited or joined we show that information
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member)
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited)
// Otherwise show the ID, unless that's already used for their name
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value
else -> null
},
onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
modifier = Modifier.fillMaxWidth()
)
}
}
}
},

View file

@ -29,10 +29,12 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
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.RoomMembershipState
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.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
@ -130,7 +132,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList())
repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
skipItems(1)
val resultState = awaitItem()
@ -175,7 +177,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList())
repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
skipItems(1)
val resultState = awaitItem()
@ -202,6 +204,53 @@ internal class RoomInviteMembersPresenterTest {
}
}
@Test
fun `present - performs search and handles unresolved results`() = runTest {
val userList = aMatrixUserList()
val joinedUser = userList[0]
val invitedUser = userList[1]
val repository = FakeUserRepository()
val presenter = RoomInviteMembersPresenter(
userRepository = repository,
roomMemberListDataSource = createDataSource(FakeMatrixRoom().apply {
givenRoomMembersState(MatrixRoomMembersState.Ready(listOf(
aRoomMember(userId = joinedUser.userId, membership = RoomMembershipState.JOIN),
aRoomMember(userId = invitedUser.userId, membership = RoomMembershipState.INVITE),
)))
}),
coroutineDispatchers = testCoroutineDispatchers()
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(RoomInviteMembersEvents.UpdateSearchQuery("some query"))
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
val unresolvedUser = UserSearchResult(aMatrixUser(id = A_USER_ID.value), isUnresolved = true)
repository.emitResult(listOf(unresolvedUser) + aMatrixUserList().map { UserSearchResult(it) })
skipItems(1)
val resultState = awaitItem()
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
val users = resultState.searchResults.users()
val userWhoShouldBeUnresolved = users.first()
assertThat(userWhoShouldBeUnresolved.isUnresolved).isTrue()
// All other users are neither joined nor invited
val otherUsers = users.minus(userWhoShouldBeUnresolved)
assertThat(otherUsers.none { it.isUnresolved }).isTrue()
}
}
@Test
fun `present - toggle users updates selected user state`() = runTest {
val repository = FakeUserRepository()
@ -254,7 +303,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser)
repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
skipItems(2)
val resultState = awaitItem()
@ -296,7 +345,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser)
repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
skipItems(2)
// And then a user is toggled

View file

@ -0,0 +1,150 @@
/*
* 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.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.R
@Composable
fun UnresolvedUserRow(
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
) {
// ID
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = id,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
)
// Warning
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = Icons.Filled.Error,
contentDescription = "",
modifier = Modifier
.size(18.dp)
.align(Alignment.Top)
.padding(2.dp),
tint = MaterialTheme.colorScheme.error,
)
Text(
text = stringResource(R.string.common_invite_unknown_profile),
color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp,
lineHeight = 16.sp,
)
}
}
}
}
@Composable
fun CheckableUnresolvedUserRow(
checked: Boolean,
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
UnresolvedUserRow(
modifier = Modifier.weight(1f),
avatarData = avatarData,
id = id,
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}
@Preview
@Composable
internal fun UnresolvedUserRowPreview() =
ElementThemedPreview {
val matrixUser = aMatrixUser()
UnresolvedUserRow(matrixUser.getAvatarData(), matrixUser.userId.value)
}
@Preview
@Composable
internal fun CheckableUnresolvedUserRowPreview() =
ElementThemedPreview {
val matrixUser = aMatrixUser()
Column {
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
}
}

View file

@ -16,10 +16,9 @@
package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
interface UserRepository {
suspend fun search(query: String): Flow<List<MatrixUser>>
suspend fun search(query: String): Flow<List<UserSearchResult>>
}

View file

@ -0,0 +1,24 @@
/*
* 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.api
import io.element.android.libraries.matrix.api.user.MatrixUser
data class UserSearchResult(
val matrixUser: MatrixUser,
val isUnresolved: Boolean = false,
)

View file

@ -23,6 +23,7 @@ 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 io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -33,24 +34,26 @@ class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource
) : UserRepository {
override suspend fun search(query: String): Flow<List<MatrixUser>> = flow {
override suspend fun search(query: String): Flow<List<UserSearchResult>> = flow {
// Manually add a fake result with the matrixId, if any
val isUserId = MatrixPatterns.isUserId(query)
if (isUserId) {
emit(listOf(MatrixUser(UserId(query))))
emit(listOf(UserSearchResult(MatrixUser(UserId(query)))))
}
if (query.length >= MINIMUM_SEARCH_LENGTH) {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource.search(query).toMutableList()
val results = dataSource.search(query).map { UserSearchResult(it) }.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)
if (isUserId && results.none { it.matrixUser.userId.value == query }) {
results.add(
0,
dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
}
emit(results)

View file

@ -23,6 +23,7 @@ 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.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -63,7 +64,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEqualTo(aMatrixUserList())
assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults())
awaitComplete()
}
}
@ -76,7 +77,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search(A_USER_ID.value)
result.test {
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)))
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult()))
skipItems(1)
awaitComplete()
}
@ -93,7 +94,7 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(searchResults)
assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults())
awaitComplete()
}
}
@ -112,13 +113,13 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(userProfile) + searchResults)
assertThat(awaitItem()).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - just shows id if profile can't be loaded`() = runTest {
fun `search - returns unresolved user if profile can't be loaded`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
@ -130,11 +131,15 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)) + searchResults)
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
awaitComplete()
}
}
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId }
private fun List<MatrixUser>.toUserSearchResults() = map { UserSearchResult(it) }
private fun placeholderResult(id: UserId = A_USER_ID, isUnresolved: Boolean = false) = UserSearchResult(MatrixUser(id), isUnresolved = isUnresolved)
}

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:439a2d9e9497486e86b63fe42402f1c2ee33b15695e47312e18867530d041dad
size 96539
oid sha256:c1c1eedbab868e0c2501220293608572850e052f685f7076ec939b3f1a9abf27
size 4464

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:255b99dea5c1da9d466533d510d7c0abe39a79cdc42b579cfa186176e9c6a49d
size 91991
oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 4457

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8e7352789223e57f85c597a591ada117c903e06ed2df49719a98e793d128cd7b
size 106579

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:91de9d54216aac2bf2c4721e5e8ff5e74be43105846bdac16230cb7bea7427af
size 13777

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:38fbef57e6fb2860d05ab2b864bab16a30ebc98199eb18b19e422f946c52e163
size 13096

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:815864d4c929c5f123bc830d367b4f983851412891a23329d03e58e7726e4280
size 54198

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56c23bd0880524cc56b713c93c5358e9f3771291c20f7a149bad00465f3f987a
size 20648
oid sha256:b13d46300c7cdd3632ee39a6bfce92a5b9bfa44917b32928da313741965addc7
size 8677

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7b91a2d05b975d568116615c286568f376ebead49e25ff17f5aae8b75be0e1f
size 28876
oid sha256:4d63e57cea65fd8617f19f2775c6cacb58fb1fd933ac26895445b49c8d8638d4
size 17363

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f12591531cfdce24378003dda48940c20d8c9cf7bfac73ac1e7713e925bfac4d
size 20214
oid sha256:255e7caab6e7133239a186a421cf669649133704b15cddebe2dbc4ca9baca372
size 8731

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44a35f28b3a59cc28937fee16eda26c26ef7b7622f929218e40f7537e096b2e8
size 28120
oid sha256:4d62a75d2faea119344221414f4442bb0682bf74a1bd71ca1cfc5ddfcaea4635
size 17342

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:372f239e351215dadf0d5c451b8105e93bc86e338f4e564aa4726d482028ae9c
size 396027
oid sha256:6e30e5a9ef2371748af9a77806168d5e49185b2e565308f31edb62b94f8060b0
size 396024

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:087ef6e2e489e63e1a30793b183915984a32265676fd5a95f02d7ca02821c84b
size 132174
oid sha256:036b362d18c690ff516de13f45aa5561902772bb4ff5a5c48383e22b27eb2999
size 132172

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5e03b31f020201e8a8bd7ad5962022337441c4e83d15e03e95f83c9fa10eaaf
size 98743
oid sha256:e4c14c033b2d4961bad5d05ae7eb61e72fc542e497bb09770271980321832607
size 98742

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479
size 393620
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
size 393618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479
size 393620
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
size 393618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479
size 393620
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
size 393618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479
size 393620
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
size 393618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd0b34e2f0b0c8e7267b4029fb7755c707ebf0e018d085d7374ebd459fbe7479
size 393620
oid sha256:bc6306a6c951e34884603f71ceae6c6fdbf32889146b60049f41f9451972dd2e
size 393618

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:712e7422e6f0ae9d6093619b156d982b0d4a861dc04c04473d3d0eb5751bd097
size 6302
oid sha256:8db2a0def5cf23917ababd10121dda386ccc44aa0172810e9ca916fbb63b08d0
size 6303

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e3db033f79ac2c77abb8c41f6331e17ca89702fe00e42fa0b3c3f68b0d331de
size 7036
oid sha256:417e4325a43fd13637f54fa71536441f2c4b5652e3bb25e2d579ef7d713c4aa9
size 7035

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:712e7422e6f0ae9d6093619b156d982b0d4a861dc04c04473d3d0eb5751bd097
size 6302
oid sha256:8db2a0def5cf23917ababd10121dda386ccc44aa0172810e9ca916fbb63b08d0
size 6303

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7e3db033f79ac2c77abb8c41f6331e17ca89702fe00e42fa0b3c3f68b0d331de
size 7036
oid sha256:417e4325a43fd13637f54fa71536441f2c4b5652e3bb25e2d579ef7d713c4aa9
size 7035

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7cdecfdb401b135159f3a4f9001a7739e4ba9a8fd80371b4a68278a07a8834c0
size 6161
oid sha256:593546b9837061cc2f0e27b94fe8a6fae80c35db62767cd43b97cc77c942c71b
size 6163

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:310058557ae80956fe325fc8a58df7b62c45040d707a0f7569f87cbb74cede07
size 6836
oid sha256:93805e6e1a168295b4263152f569b3b7065e890c11870f7e21f5b0f5cc1b07a6
size 6834

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7cdecfdb401b135159f3a4f9001a7739e4ba9a8fd80371b4a68278a07a8834c0
size 6161
oid sha256:593546b9837061cc2f0e27b94fe8a6fae80c35db62767cd43b97cc77c942c71b
size 6163

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:310058557ae80956fe325fc8a58df7b62c45040d707a0f7569f87cbb74cede07
size 6836
oid sha256:93805e6e1a168295b4263152f569b3b7065e890c11870f7e21f5b0f5cc1b07a6
size 6834

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3dc781e3f17f09eb7e0cb8bf753a68ebde850b0b618e8d6bff7c780e365ee778
size 11853
oid sha256:ff1f9f65252e855037d6008aea0d884ccaec6419412b26e4ad12a32ab583f5ca
size 11848

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d14b21a4723db7ac5ceda97eee44681d5d236007262e0e9e8b5a8d5abf8022b
oid sha256:37c370bc41527d7b407679c971a35aee051da93c033c2f195b6c3d6c7b2d885a
size 11514

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:121e9eb7013123e5647e351eda7d331ade8032ae5aa56bd1be943ee6c6acad53
size 41444
oid sha256:f99c5fd192b3d56188dc0a14a522b1ea4a05cdbe9b18f63c4a4c99f0914c0df5
size 41443

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e19a24fa27305822af8ec3a4dfac727eb1297ccbbeffec726664c6bc29b1a2a4
size 53421
oid sha256:8a524ff4463d72a0adca6a13907d95c6fb2cfe8afeba541cfe03c8fb44202d4f
size 53427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9ab84b59725483b7187e91f3df18d1d55decbef7da37e186099027c6c98e3430
oid sha256:6c7090f345c722c0c5d5d415723e1770843d36fef48dc750a893320830b9dfd4
size 46068

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b388b6664ea0e5a86d6506a954cbcdc36c39054204739365eb0228b2e1d4fd4
size 197778
oid sha256:b876f9b49579215a6f0cbb73627e6c5ad19e7b71131bee02a2fe4b7f46432a67
size 197787

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbeca10cc6a489dd20390bbc3bf5f2fa4f726342083621b8161a2a4448622ffc
size 198122
oid sha256:6d358680e2b08ed4d4f709b9f4d908f5cad84b888a7aa74b49609b09297e416e
size 198126

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23ed23fbc4a47152c4d9b6373111c7de5497900e9ccb3382f99364bde2444032
oid sha256:f8f54ea6a7d158e8f7da56648aabf05c8dbc761db8698891d2d3f1ff863b76fb
size 51724

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a715939660ec1a2bea4c5a3e9d0a3b4b0bc45f137fae4c3210476145346a3a9
oid sha256:4c2845cd6ef914a8062d3685d62d6b861a3963e09aad13efd5a9ac5e2de1a3ea
size 73507

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f3094dab56dd2c46a5c34162e717afcf674823be080959ec5e852227946237ff
oid sha256:8dbf4c20504f68324ab8606d84f41390bef28479f5e0f9d76d34c358403b0de8
size 42954

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:44534d8513114e666e7faa341a81184420fd84e8c3723c2d17236949f31e2fdb
size 55118
oid sha256:3defc06e2e3d791261e87048fb019d2da7e8372d35550bcb17aacb2bb62370eb
size 55127

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f988cd33ae74aa292f26f0c6f11b5fcec8ff298005423161921e912a5e416c97
size 39341
oid sha256:824b93fd466fff749da6f6314fecbc1a15d316b3cac201adac0137c28660e121
size 39342

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9a65842cd12943594ed28ffba0a958ff076d6c6492533bfc5ffd395b915b2b30
size 56467
oid sha256:8233a3eafef97e18dcd6b846d78af3e3a6a4fb79b89af276a1a41067f9e9af45
size 56472

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f7822ba6ecc9e7cf7bfdb4446b6796dce685d2417f6ed29b6ef5015438e09ace
size 40935
oid sha256:fbe93775a53dc0e06a019d2fd3c72612dfbf9000709f48b4cdcc14ef72c914ad
size 40934

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3d8fae3d49c61850eef15c762d1f9615a735ca1943e78aa4dc116eaa830c02a
size 53182
oid sha256:4703dc63b3015c2a2c96e731dbabe55f2cff07c5508853f370d4eeb67e67740a
size 53186

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ebc4e13d35c918274f35445db57955780bfdc037d5bd85a98eddb9b3bac42d5e
size 45846
oid sha256:70a1848c08f7e3ed4fcc2cd0b05344234b2c4a2c26139c256344b102b49d98a1
size 45847

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed3f266ce286216deca3747ded2b46bd7ef37c707145d1ae676e8afe227850b2
size 199651
oid sha256:4fe993ac7679fef189bcbfdf9e82abfb0263686721fcabb4ddeca8d2a3d4e209
size 199655

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:685a607ef2c644b8844a1a83bc9427040f3890b4b9f777aa80592c98f0a96d87
size 199963
oid sha256:fa118c2f28fe2c7bd3a194cf80cb25ece8c61fd15edd407d93ee76816c362fe1
size 199975

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d5975a89ee4cf263317079be95b6f6cc199bfbbcf75ac88d622989bfd859c813
size 51801
oid sha256:022ca7f2b5ce17e0079346778ee3b99b1e235439f88885a2d1bf545cc11864e6
size 51800

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0375a01da0c470b8379d2edcfb282b4b55f5cee86dc9da3efbe66fbadf7019a5
size 74079
oid sha256:c347c51252c8e1305385c6c92936941403c481c792ba3fe10ca4949a1accd051
size 74080

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f085dfb5b0147374af21b8df3f4cd2b9930e638188add6f3a4fa94008747c7ee
size 42553
oid sha256:51120566fc91498097a2e68232f04342ade84d4aee702d5cc06ee5ff4bc17460
size 42551

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ec8c64bb4500bce08725d74753f45db93a369095626064ecdf9a83122aea326
size 55285
oid sha256:548566862bbb0da3e13b43aee882b28656ea544b9341d6152d4d1f5c526ed3f2
size 55292

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54046d58d89dd8f44e8e2fdb83306682fd6305a32ba91c06c9bcd042e99bbdd3
oid sha256:092598b99ed8d53381f80352c357b13b159dd65cb23ef54d277bfa862de28263
size 38928

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a96004c7012bd1b83eb395511472a6c93f410f9504656f4e3621d7809eccca9f
size 56483
oid sha256:0dd943e910de80c7a36db12acb17b0ee116228181ddb58a45c8ec9252e85e435
size 56491

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49759fbb46a966d0e0f1c793545e320e7588ac482399a12ac45762474e6781f8
size 45579
oid sha256:74d763ff8d44e686ee3a10dcadc0e2c1f02e3332c486d62d00e2333d0c3b60cf
size 45578

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdca9e94c071c20753aa2076f6c3a7ed931a2c1e90a78c29e00f27f7af99dfe8
size 45758
oid sha256:09b29e2e7bb8c38c978d3b56b04a97fc96d752154bb78fddd13a5cde8051a048
size 45757

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e47966820a6a77efece51aaac5adbe3dd1dad1880b1feb7da2e86e36b386f7e
size 43815
oid sha256:a936d98080f35c838adab660ef9b9287fc380d1358335c54e3da56230476721f
size 43810

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc3fc4c97a43c5786a75328e35828ed0e0cfd74ffab673832053ecc11db9b5e0
size 44852
oid sha256:43042ccb540b5fa3b8d5d9e273bd1155258d0dc12875b95c7a364492b3985da2
size 44854

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7f82a51b6b6ed4ac030c4602da9da2629010169e998aaba16f9e05c652f6010d
size 45283
oid sha256:16cf470ec6ff50ee469db7bdc2cbfc1e32c5bcd7835c12c7313aafa495a44825
size 45282

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d18843f61267ae7385c23113be5c5c3d4376d95a843eef126ed670877f69b80
size 42281
oid sha256:e6cc29403f3b3e146e9a637973320f5bd6bf73feb7b042f8844337817e551113
size 42280

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c820bd324df729db08710d1a6c17ca34a451a3bb94da2206fc57d6b4efe91e2f
size 60019
oid sha256:b53d55b5085673ac3a0a7663f7ff68e7d510fe352f3931b49c7e75e4c3767b93
size 60007

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da2f9f87d89382b4b18d39a477a8e86f98067e2326adae4def8374b5bf09f316
size 57588
oid sha256:a761318f3dfbc2ce6e777cfabc19eeb2f89100ae7631660f4b4d7550cd947c84
size 57580

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:628f1f00dca9d15faabd8288a3c54b1b8581a380cf14edf364f0dee9ecc187d5
oid sha256:147786f2cfcf7674147253cca5e41b4af96587a27216076a1c0802c81b0b1b46
size 180117

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:827bccb0fdd3106fb24a95d665b4da2cfc15de48e8f508ae809c9f75d6d1bbf0
size 178915
oid sha256:789a979f7207531b4b9ba488ae2de52e6046809e788baf6690e1383a933cf624
size 178916

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb1178f063eafb21ba01b93310d84335afbc4e6ad1fc99d1776578fe3f7eaa07
size 49237

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca9a896e2dd3cf47792467f052f2d45b555d99ccbd91436ef4c03dfca66836ab
size 47542

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:33e3ba31ada4522ce7c9b6adafd4c1c37df1927017e4bba8bf3f418a352a0cc9
size 129286

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bd094880b88662dd76409e4a728eda3736700c59c0721b87ba2568ee4bc0782
size 39296