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 0f27ad5a84
commit 36d372e1eb
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableMatrixUserRow 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.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable @Composable
fun SearchMultipleUsersResultItem( fun SearchMultipleUsersResultItem(
matrixUser: MatrixUser, searchResult: UserSearchResult,
isUserSelected: Boolean, isUserSelected: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
) { ) {
CheckableMatrixUserRow( if (searchResult.isUnresolved) {
checked = isUserSelected, CheckableUnresolvedUserRow(
modifier = modifier, checked = isUserSelected,
matrixUser = matrixUser, modifier = modifier,
avatarSize = AvatarSize.Custom(36.dp), avatarData = searchResult.matrixUser.getAvatarData(AvatarSize.Custom(36.dp)),
onCheckedChange = onCheckedChange, id = searchResult.matrixUser.userId.value,
) onCheckedChange = onCheckedChange,
)
} else {
CheckableMatrixUserRow(
checked = isUserSelected,
modifier = modifier,
matrixUser = searchResult.matrixUser,
avatarSize = AvatarSize.Custom(36.dp),
onCheckedChange = onCheckedChange,
)
}
} }
@Preview @Preview
@Composable @Composable
internal fun SearchMultipleUsersResultItemLightPreview() = ElementPreviewLight { ContentToPreview() } internal fun SearchMultipleUsersResultItemPreview() = ElementThemedPreview { ContentToPreview() }
@Preview
@Composable
internal fun SearchMultipleUsersResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
Column { Column {
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), isUserSelected = true) SearchMultipleUsersResultItem(searchResult = UserSearchResult(aMatrixUser(), isUnresolved = false), isUserSelected = false)
SearchMultipleUsersResultItem(matrixUser = aMatrixUser(), 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 package io.element.android.features.createroom.impl.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow 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.components.aMatrixUser
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.usersearch.api.UserSearchResult
@Composable @Composable
fun SearchSingleUserResultItem( fun SearchSingleUserResultItem(
matrixUser: MatrixUser, searchResult: UserSearchResult,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
MatrixUserRow( if (searchResult.isUnresolved) {
modifier = modifier.clickable(onClick = onClick), UnresolvedUserRow(
matrixUser = matrixUser, modifier = modifier.clickable(onClick = onClick),
avatarSize = AvatarSize.Custom(36.dp), 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 @Preview
@Composable @Composable
internal fun SearchSingleUserResultItemLightPreview() = ElementPreviewLight { ContentToPreview() } internal fun SearchSingleUserResultItemPreview() = ElementThemedPreview{ ContentToPreview() }
@Preview
@Composable
internal fun SearchSingleUserResultItemDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable @Composable
private fun ContentToPreview() { 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.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.ui.strings.R import io.element.android.libraries.ui.strings.R
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Composable @Composable
fun SearchUserBar( fun SearchUserBar(
query: String, query: String,
state: SearchBarResultState<ImmutableList<MatrixUser>>, state: SearchBarResultState<ImmutableList<UserSearchResult>>,
selectedUsers: ImmutableList<MatrixUser>, selectedUsers: ImmutableList<MatrixUser>,
active: Boolean, active: Boolean,
isMultiSelectionEnabled: Boolean, isMultiSelectionEnabled: Boolean,
@ -68,26 +69,26 @@ fun SearchUserBar(
resultHandler = { users -> resultHandler = { users ->
LazyColumn { LazyColumn {
if (isMultiSelectionEnabled) { if (isMultiSelectionEnabled) {
items(users) { matrixUser -> items(users) { searchResult ->
SearchMultipleUsersResultItem( SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser, searchResult = searchResult,
isUserSelected = selectedUsers.find { it.userId == matrixUser.userId } != null, isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if (checked) { if (checked) {
onUserSelected(matrixUser) onUserSelected(searchResult.matrixUser)
} else { } else {
onUserDeselected(matrixUser) onUserDeselected(searchResult.matrixUser)
} }
} }
) )
} }
} else { } else {
items(users) { matrixUser -> items(users) { searchResult ->
SearchSingleUserResultItem( SearchSingleUserResultItem(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
matrixUser = matrixUser, searchResult = searchResult,
onClick = { onUserSelected(matrixUser) } onClick = { onUserSelected(searchResult.matrixUser) }
) )
} }
} }

View file

@ -30,8 +30,8 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope 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.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -56,7 +56,7 @@ 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: SearchBarResultState<ImmutableList<MatrixUser>> by remember { var searchResults: SearchBarResultState<ImmutableList<UserSearchResult>> by remember {
mutableStateOf(SearchBarResultState.NotSearching()) 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.designsystem.theme.components.SearchBarResultState
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.UserSearchResult
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class UserListState( data class UserListState(
val searchQuery: String, val searchQuery: String,
val searchResults: SearchBarResultState<ImmutableList<MatrixUser>>, val searchResults: SearchBarResultState<ImmutableList<UserSearchResult>>,
val selectedUsers: ImmutableList<MatrixUser>, val selectedUsers: ImmutableList<MatrixUser>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val selectionMode: SelectionMode, 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState 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 io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -38,14 +39,14 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
isSearchActive = true, isSearchActive = true,
searchQuery = "@someone:matrix.org", searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(), selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.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 = SearchBarResultState.Results(aMatrixUserList().toImmutableList()), searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
), ),
aUserListState().copy( aUserListState().copy(
isSearchActive = true, isSearchActive = true,

View file

@ -5,4 +5,4 @@
<string name="screen_create_room_add_people_title">"Añadir personas"</string> <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_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> <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_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_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> <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_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_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> <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.designsystem.theme.components.SearchBarResultState
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.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserRepository 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
@ -130,18 +131,18 @@ class DefaultUserListPresenterTests {
skipItems(2) skipItems(2)
// When the user repository emits a result, it's copied to the state // 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( assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results( SearchBarResultState.Results(
persistentListOf(aMatrixUser()) persistentListOf(UserSearchResult(aMatrixUser()))
) )
) )
// When the user repository emits another result, it replaces the previous value // 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( assertThat(awaitItem().searchResults).isEqualTo(
SearchBarResultState.Results( 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.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.leaveroom.api.LeaveRoomView
import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs import io.element.android.features.roomdetails.impl.blockuser.BlockUserDialogs
import io.element.android.features.roomdetails.impl.blockuser.BlockUserSection import io.element.android.features.roomdetails.impl.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.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
@ -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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
@ -185,7 +186,7 @@ internal fun RoomHeaderSection(
@Composable @Composable
internal fun TopicSection(roomTopic: String, modifier: Modifier = Modifier) { 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( Text(
roomTopic, roomTopic,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp), 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 { userRepository.search(searchQuery).collect {
searchResults.value = when { searchResults.value = when {
it.isEmpty() -> SearchBarResultState.NoResults() it.isEmpty() -> SearchBarResultState.NoResults()
else -> SearchBarResultState.Results(it.map { user -> else -> SearchBarResultState.Results(it.map { result ->
val existingMembership = joinedMembers.firstOrNull { j -> j.userId == user.userId }?.membership val existingMembership = joinedMembers.firstOrNull { j -> j.userId == result.matrixUser.userId }?.membership
val isJoined = existingMembership == RoomMembershipState.JOIN val isJoined = existingMembership == RoomMembershipState.JOIN
val isInvited = existingMembership == RoomMembershipState.INVITE val isInvited = existingMembership == RoomMembershipState.INVITE
InvitableUser( InvitableUser(
matrixUser = user, matrixUser = result.matrixUser,
isSelected = selectedUsers.value.contains(user) || isJoined || isInvited, isSelected = selectedUsers.value.contains(result.matrixUser) || isJoined || isInvited,
isAlreadyJoined = isJoined, isAlreadyJoined = isJoined,
isAlreadyInvited = isInvited, isAlreadyInvited = isInvited,
isUnresolved = result.isUnresolved,
) )
}.toImmutableList()) }.toImmutableList())
} }

View file

@ -35,4 +35,5 @@ data class InvitableUser(
val isSelected: Boolean = false, val isSelected: Boolean = false,
val isAlreadyJoined: Boolean = false, val isAlreadyJoined: Boolean = false,
val isAlreadyInvited: 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.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.user.MatrixUser 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.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersList import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.matrix.ui.model.getAvatarData
@ -180,22 +181,32 @@ private fun RoomInviteMembersSearchBar(
LazyColumn { LazyColumn {
items(results) { invitableUser -> items(results) { invitableUser ->
CheckableUserRow( if (invitableUser.isUnresolved && !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined) {
checked = invitableUser.isSelected, CheckableUnresolvedUserRow(
enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined, checked = invitableUser.isSelected,
avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM), avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
name = invitableUser.matrixUser.getBestName(), id = invitableUser.matrixUser.userId.value,
subtext = when { onCheckedChange = { onUserToggled(invitableUser.matrixUser) },
// If they're already invited or joined we show that information modifier = Modifier.fillMaxWidth()
invitableUser.isAlreadyJoined -> stringResource(R.string.screen_room_details_already_a_member) )
invitableUser.isAlreadyInvited -> stringResource(R.string.screen_room_details_already_invited) } else {
// Otherwise show the ID, unless that's already used for their name CheckableUserRow(
invitableUser.matrixUser.displayName.isNullOrEmpty().not() -> invitableUser.matrixUser.userId.value checked = invitableUser.isSelected,
else -> null enabled = !invitableUser.isAlreadyInvited && !invitableUser.isAlreadyJoined,
}, avatarData = invitableUser.matrixUser.getAvatarData(AvatarSize.MEDIUM),
onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, name = invitableUser.matrixUser.getBestName(),
modifier = Modifier.fillMaxWidth() 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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.A_USER_ID_2
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList 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.libraries.usersearch.test.FakeUserRepository
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -130,7 +132,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList()) repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
skipItems(1) skipItems(1)
val resultState = awaitItem() val resultState = awaitItem()
@ -175,7 +177,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList()) repository.emitResult(aMatrixUserList().map { UserSearchResult(it) })
skipItems(1) skipItems(1)
val resultState = awaitItem() 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 @Test
fun `present - toggle users updates selected user state`() = runTest { fun `present - toggle users updates selected user state`() = runTest {
val repository = FakeUserRepository() val repository = FakeUserRepository()
@ -254,7 +303,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser) repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
skipItems(2) skipItems(2)
val resultState = awaitItem() val resultState = awaitItem()
@ -296,7 +345,7 @@ internal class RoomInviteMembersPresenterTest {
skipItems(1) skipItems(1)
assertThat(repository.providedQuery).isEqualTo("some query") assertThat(repository.providedQuery).isEqualTo("some query")
repository.emitResult(aMatrixUserList() + selectedUser) repository.emitResult((aMatrixUserList() + selectedUser).map { UserSearchResult(it) })
skipItems(2) skipItems(2)
// And then a user is toggled // 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 package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface UserRepository { 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.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource import io.element.android.libraries.usersearch.api.UserListDataSource
import io.element.android.libraries.usersearch.api.UserRepository import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -33,24 +34,26 @@ class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource private val dataSource: UserListDataSource
) : UserRepository { ) : 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 // Manually add a fake result with the matrixId, if any
val isUserId = MatrixPatterns.isUserId(query) val isUserId = MatrixPatterns.isUserId(query)
if (isUserId) { if (isUserId) {
emit(listOf(MatrixUser(UserId(query)))) emit(listOf(UserSearchResult(MatrixUser(UserId(query)))))
} }
if (query.length >= MINIMUM_SEARCH_LENGTH) { if (query.length >= MINIMUM_SEARCH_LENGTH) {
// Debounce // Debounce
delay(DEBOUNCE_TIME_MILLIS) 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 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 }) { if (isUserId && results.none { it.matrixUser.userId.value == query }) {
val getProfileResult: MatrixUser? = dataSource.getProfile(UserId(query)) results.add(
val profile = getProfileResult ?: MatrixUser(UserId(query)) 0,
results.add(0, profile) dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
} }
emit(results) 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_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.ui.components.aMatrixUserList 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 io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -63,7 +64,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search("some query") val result = repository.search("some query")
result.test { result.test {
assertThat(awaitItem()).isEqualTo(aMatrixUserList()) assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults())
awaitComplete() awaitComplete()
} }
} }
@ -76,7 +77,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search(A_USER_ID.value) val result = repository.search(A_USER_ID.value)
result.test { result.test {
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID))) assertThat(awaitItem()).isEqualTo(listOf(placeholderResult()))
skipItems(1) skipItems(1)
awaitComplete() awaitComplete()
} }
@ -93,7 +94,7 @@ internal class MatrixUserRepositoryTest {
result.test { result.test {
skipItems(1) skipItems(1)
assertThat(awaitItem()).isEqualTo(searchResults) assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults())
awaitComplete() awaitComplete()
} }
} }
@ -112,13 +113,13 @@ internal class MatrixUserRepositoryTest {
result.test { result.test {
skipItems(1) skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(userProfile) + searchResults) assertThat(awaitItem()).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
awaitComplete() awaitComplete()
} }
} }
@Test @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 searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource() val dataSource = FakeUserListDataSource()
@ -130,11 +131,15 @@ internal class MatrixUserRepositoryTest {
result.test { result.test {
skipItems(1) skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)) + searchResults) assertThat(awaitItem()).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
awaitComplete() awaitComplete()
} }
} }
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId } 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 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.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -26,14 +26,14 @@ class FakeUserRepository : UserRepository {
var providedQuery: String? = null var providedQuery: String? = null
private set 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 providedQuery = query
return flow return flow
} }
suspend fun emitResult(result: List<MatrixUser>) { suspend fun emitResult(result: List<UserSearchResult>) {
flow.emit(result) flow.emit(result)
} }

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:255b99dea5c1da9d466533d510d7c0abe39a79cdc42b579cfa186176e9c6a49d oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b
size 91991 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 version https://git-lfs.github.com/spec/v1
oid sha256:56c23bd0880524cc56b713c93c5358e9f3771291c20f7a149bad00465f3f987a oid sha256:b13d46300c7cdd3632ee39a6bfce92a5b9bfa44917b32928da313741965addc7
size 20648 size 8677

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:827bccb0fdd3106fb24a95d665b4da2cfc15de48e8f508ae809c9f75d6d1bbf0 oid sha256:789a979f7207531b4b9ba488ae2de52e6046809e788baf6690e1383a933cf624
size 178915 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