Categorise members by role in the ChangeRoles screen (#2595)
* Categorise members by role in the ChangeRoles screen * Fix automatic reload of member list when either the membership or power levels change * Replace empty space with disabled checkbox * Add 'pending' label to members who are in invited state * Implement new designs * Fix string issue in confirm recovery key screen * Update screenshots --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
e01af9033b
commit
4057b4b9cd
66 changed files with 542 additions and 241 deletions
1
changelog.d/2593.misc
Normal file
1
changelog.d/2593.misc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Categorise members by role in change roles screen.
|
||||||
|
|
@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
|
||||||
ChangeRolesView(
|
ChangeRolesView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = this::navigateUp,
|
navigateUp = this::navigateUp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||||
var query by rememberSaveable { mutableStateOf<String?>(null) }
|
var query by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchResults by remember {
|
var searchResults by remember {
|
||||||
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
|
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
|
||||||
}
|
}
|
||||||
val selectedUsers = remember {
|
val selectedUsers = remember {
|
||||||
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
|
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
|
||||||
|
|
@ -91,7 +91,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||||
// Users who were selected but didn't have the role, so their role change was pending
|
// Users who were selected but didn't have the role, so their role change was pending
|
||||||
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
|
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
|
||||||
// Users who no longer have the role
|
// Users who no longer have the role
|
||||||
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }
|
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet()
|
||||||
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
|
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
|
|
@ -103,8 +103,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||||
LaunchedEffect(query, roomMemberState) {
|
LaunchedEffect(query, roomMemberState) {
|
||||||
val results = dataSource
|
val results = dataSource
|
||||||
.search(query.orEmpty())
|
.search(query.orEmpty())
|
||||||
.sorted()
|
.groupedByRole()
|
||||||
|
|
||||||
|
println(results)
|
||||||
searchResults = if (results.isEmpty()) {
|
searchResults = if (results.isEmpty()) {
|
||||||
SearchBarResultState.NoResultsFound()
|
SearchBarResultState.NoResultsFound()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -181,6 +182,14 @@ class ChangeRolesPresenter @AssistedInject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<RoomMember>.groupedByRole(): MembersByRole {
|
||||||
|
return MembersByRole(
|
||||||
|
admins = filter { it.role == RoomMember.Role.ADMIN }.sorted(),
|
||||||
|
moderators = filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
|
||||||
|
members = filter { it.role == RoomMember.Role.USER }.sorted(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,20 @@
|
||||||
|
|
||||||
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
|
package io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
|
||||||
|
|
||||||
|
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
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.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
data class ChangeRolesState(
|
data class ChangeRolesState(
|
||||||
val role: RoomMember.Role,
|
val role: RoomMember.Role,
|
||||||
val query: String?,
|
val query: String?,
|
||||||
val isSearchActive: Boolean,
|
val isSearchActive: Boolean,
|
||||||
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
|
val searchResults: SearchBarResultState<MembersByRole>,
|
||||||
val selectedUsers: ImmutableList<MatrixUser>,
|
val selectedUsers: ImmutableList<MatrixUser>,
|
||||||
val hasPendingChanges: Boolean,
|
val hasPendingChanges: Boolean,
|
||||||
val exitState: AsyncAction<Unit>,
|
val exitState: AsyncAction<Unit>,
|
||||||
|
|
@ -35,3 +37,21 @@ data class ChangeRolesState(
|
||||||
val canChangeMemberRole: (UserId) -> Boolean,
|
val canChangeMemberRole: (UserId) -> Boolean,
|
||||||
val eventSink: (ChangeRolesEvent) -> Unit,
|
val eventSink: (ChangeRolesEvent) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MembersByRole(
|
||||||
|
val admins: ImmutableList<RoomMember>,
|
||||||
|
val moderators: ImmutableList<RoomMember>,
|
||||||
|
val members: ImmutableList<RoomMember>,
|
||||||
|
) {
|
||||||
|
constructor(members: List<RoomMember>) : this(
|
||||||
|
admins = members.filter { it.role == RoomMember.Role.ADMIN }.sorted(),
|
||||||
|
moderators = members.filter { it.role == RoomMember.Role.MODERATOR }.sorted(),
|
||||||
|
members = members.filter { it.role == RoomMember.Role.USER }.sorted(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isEmpty() = admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||||
|
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||||
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.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
@ -32,7 +33,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||||
override val values: Sequence<ChangeRolesState>
|
override val values: Sequence<ChangeRolesState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aChangeRolesState(),
|
aChangeRolesState(),
|
||||||
aChangeRolesState(role = RoomMember.Role.MODERATOR),
|
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
|
||||||
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
|
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
|
||||||
aChangeRolesStateWithSelectedUsers(),
|
aChangeRolesStateWithSelectedUsers(),
|
||||||
aChangeRolesStateWithSelectedUsers().copy(
|
aChangeRolesStateWithSelectedUsers().copy(
|
||||||
|
|
@ -41,7 +42,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||||
aChangeRolesStateWithSelectedUsers().copy(
|
aChangeRolesStateWithSelectedUsers().copy(
|
||||||
query = "Alice",
|
query = "Alice",
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
|
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
|
||||||
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
|
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
|
||||||
),
|
),
|
||||||
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
|
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
|
||||||
|
|
@ -56,7 +57,7 @@ internal fun aChangeRolesState(
|
||||||
role: RoomMember.Role = RoomMember.Role.ADMIN,
|
role: RoomMember.Role = RoomMember.Role.ADMIN,
|
||||||
query: String? = null,
|
query: String? = null,
|
||||||
isSearchActive: Boolean = false,
|
isSearchActive: Boolean = false,
|
||||||
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
|
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
|
||||||
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||||
hasPendingChanges: Boolean = false,
|
hasPendingChanges: Boolean = false,
|
||||||
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
|
@ -78,7 +79,17 @@ internal fun aChangeRolesState(
|
||||||
|
|
||||||
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
|
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
|
||||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||||
searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
|
searchResults = SearchBarResultState.Results(
|
||||||
|
MembersByRole(
|
||||||
|
members = aRoomMemberList().mapIndexed { index, roomMember ->
|
||||||
|
if (index % 2 == 0) {
|
||||||
|
roomMember.copy(membership = RoomMembershipState.INVITE)
|
||||||
|
} else {
|
||||||
|
roomMember
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
hasPendingChanges = true,
|
hasPendingChanges = true,
|
||||||
canRemoveMember = { it != UserId("@alice:server.org") },
|
canRemoveMember = { it != UserId("@alice:server.org") },
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
|
@ -36,12 +38,16 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
|
@ -52,6 +58,9 @@ import io.element.android.libraries.designsystem.components.async.AsyncActionVie
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
|
||||||
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||||
|
|
@ -67,22 +76,22 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
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.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.api.room.getBestName
|
||||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
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.MatrixUserRow
|
|
||||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChangeRolesView(
|
fun ChangeRolesView(
|
||||||
state: ChangeRolesState,
|
state: ChangeRolesState,
|
||||||
onBackPressed: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
|
val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp)
|
||||||
BackHandler(enabled = !state.isSearchActive) {
|
BackHandler(enabled = !state.isSearchActive) {
|
||||||
state.eventSink(ChangeRolesEvent.Exit)
|
state.eventSink(ChangeRolesEvent.Exit)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +145,7 @@ fun ChangeRolesView(
|
||||||
resultState = state.searchResults,
|
resultState = state.searchResults,
|
||||||
) { members ->
|
) { members ->
|
||||||
SearchResultsList(
|
SearchResultsList(
|
||||||
isSearchActive = true,
|
currentRole = state.role,
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
searchResults = members,
|
searchResults = members,
|
||||||
selectedUsers = state.selectedUsers,
|
selectedUsers = state.selectedUsers,
|
||||||
|
|
@ -152,9 +161,9 @@ fun ChangeRolesView(
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
SearchResultsList(
|
SearchResultsList(
|
||||||
isSearchActive = false,
|
currentRole = state.role,
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
|
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
|
||||||
selectedUsers = state.selectedUsers,
|
selectedUsers = state.selectedUsers,
|
||||||
canRemoveMember = state.canChangeMemberRole,
|
canRemoveMember = state.canChangeMemberRole,
|
||||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
|
||||||
|
|
@ -179,7 +188,7 @@ fun ChangeRolesView(
|
||||||
|
|
||||||
AsyncActionView(
|
AsyncActionView(
|
||||||
async = state.exitState,
|
async = state.exitState,
|
||||||
onSuccess = { updatedOnBackPressed() },
|
onSuccess = { updatedNavigateUp() },
|
||||||
confirmationDialog = {
|
confirmationDialog = {
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
|
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
|
||||||
|
|
@ -227,8 +236,8 @@ fun ChangeRolesView(
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultsList(
|
private fun SearchResultsList(
|
||||||
isSearchActive: Boolean,
|
currentRole: RoomMember.Role,
|
||||||
searchResults: ImmutableList<RoomMember>,
|
searchResults: MembersByRole,
|
||||||
selectedUsers: ImmutableList<MatrixUser>,
|
selectedUsers: ImmutableList<MatrixUser>,
|
||||||
canRemoveMember: (UserId) -> Boolean,
|
canRemoveMember: (UserId) -> Boolean,
|
||||||
onSelectionToggled: (RoomMember) -> Unit,
|
onSelectionToggled: (RoomMember) -> Unit,
|
||||||
|
|
@ -241,44 +250,146 @@ private fun SearchResultsList(
|
||||||
item {
|
item {
|
||||||
selectedUsersList(selectedUsers)
|
selectedUsersList(selectedUsers)
|
||||||
}
|
}
|
||||||
stickyHeader {
|
if (searchResults.admins.isNotEmpty()) {
|
||||||
val textResId = if (isSearchActive) {
|
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
|
||||||
CommonStrings.common_search_results
|
// Add a footer for the admin section in change role to moderator screen
|
||||||
} else {
|
if (currentRole == RoomMember.Role.MODERATOR) {
|
||||||
R.string.screen_room_member_list_room_members_header_title
|
item {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
text = stringResource(R.string.screen_room_change_role_moderators_admin_section_footer),
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
items(searchResults.admins, key = { it.userId }) { roomMember ->
|
||||||
|
ListMemberItem(
|
||||||
|
roomMember = roomMember,
|
||||||
|
canRemoveMember = canRemoveMember,
|
||||||
|
onSelectionToggled = onSelectionToggled,
|
||||||
|
selectedUsers = selectedUsers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchResults.moderators.isNotEmpty()) {
|
||||||
|
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_moderators)) }
|
||||||
|
items(searchResults.moderators, key = { it.userId }) { roomMember ->
|
||||||
|
ListMemberItem(
|
||||||
|
roomMember = roomMember,
|
||||||
|
canRemoveMember = canRemoveMember,
|
||||||
|
onSelectionToggled = onSelectionToggled,
|
||||||
|
selectedUsers = selectedUsers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchResults.admins.isNotEmpty()) {
|
||||||
|
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_member_list_mode_members)) }
|
||||||
|
items(searchResults.members, key = { it.userId }) { roomMember ->
|
||||||
|
ListMemberItem(
|
||||||
|
roomMember = roomMember,
|
||||||
|
canRemoveMember = canRemoveMember,
|
||||||
|
onSelectionToggled = onSelectionToggled,
|
||||||
|
selectedUsers = selectedUsers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListSectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(ElementTheme.colors.bgCanvasDefault)
|
.background(ElementTheme.colors.bgCanvasDefault)
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
text = stringResource(textResId),
|
text = text,
|
||||||
style = ElementTheme.typography.fontBodyLgMedium,
|
style = ElementTheme.typography.fontBodyLgMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(searchResults, key = { it.userId }) { roomMember ->
|
|
||||||
|
@Composable
|
||||||
|
private fun ListMemberItem(
|
||||||
|
roomMember: RoomMember,
|
||||||
|
canRemoveMember: (UserId) -> Boolean,
|
||||||
|
onSelectionToggled: (RoomMember) -> Unit,
|
||||||
|
selectedUsers: ImmutableList<MatrixUser>,
|
||||||
|
) {
|
||||||
val canToggle = canRemoveMember(roomMember.userId)
|
val canToggle = canRemoveMember(roomMember.userId)
|
||||||
val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
|
val trailingContent: @Composable (() -> Unit) = {
|
||||||
{
|
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = selectedUsers.any { it.userId == roomMember.userId },
|
checked = selectedUsers.any { it.userId == roomMember.userId },
|
||||||
onCheckedChange = { onSelectionToggled(roomMember) },
|
onCheckedChange = { onSelectionToggled(roomMember) },
|
||||||
|
enabled = canToggle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
MemberRow(
|
||||||
null
|
|
||||||
}
|
|
||||||
MatrixUserRow(
|
|
||||||
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
|
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
|
||||||
matrixUser = MatrixUser(
|
avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem),
|
||||||
userId = roomMember.userId,
|
name = roomMember.getBestName(),
|
||||||
displayName = roomMember.displayName,
|
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
|
||||||
avatarUrl = roomMember.avatarUrl,
|
isPending = roomMember.membership == RoomMembershipState.INVITE,
|
||||||
),
|
|
||||||
trailingContent = trailingContent,
|
trailingContent = trailingContent,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MemberRow(
|
||||||
|
avatarData: AvatarData,
|
||||||
|
name: String,
|
||||||
|
userId: String?,
|
||||||
|
isPending: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
trailingContent: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp)
|
||||||
|
.padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Avatar(avatarData)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp)
|
||||||
|
.weight(1f),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Name
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
text = name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
)
|
||||||
|
// Invitation pending marker
|
||||||
|
if (isPending) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
|
text = stringResource(id = R.string.screen_room_member_list_pending_header_title),
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular.copy(fontStyle = FontStyle.Italic),
|
||||||
|
color = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Id
|
||||||
|
userId?.let {
|
||||||
|
Text(
|
||||||
|
text = userId,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = ElementTheme.typography.fontBodySmRegular,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailingContent?.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
@ -287,7 +398,27 @@ internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
ChangeRolesView(
|
ChangeRolesView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = {},
|
navigateUp = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun PendingMemberRowWithLongNamePreview() {
|
||||||
|
ElementPreview {
|
||||||
|
MemberRow(
|
||||||
|
avatarData = AvatarData("userId", "A very long name that should be truncated", "https://example.com/avatar.png", AvatarSize.UserListItem),
|
||||||
|
name = "A very long name that should be truncated",
|
||||||
|
userId = "@alice:matrix.org",
|
||||||
|
isPending = true,
|
||||||
|
trailingContent = {
|
||||||
|
Checkbox(
|
||||||
|
checked = true,
|
||||||
|
onCheckedChange = {},
|
||||||
|
enabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,15 +106,19 @@ class ChangeRolesPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||||
assertThat(initialResults).hasSize(10)
|
assertThat(initialResults?.members).hasSize(8)
|
||||||
|
assertThat(initialResults?.moderators).hasSize(1)
|
||||||
|
assertThat(initialResults?.admins).hasSize(1)
|
||||||
|
|
||||||
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
|
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||||
assertThat(searchResults).hasSize(1)
|
assertThat(searchResults?.admins).hasSize(1)
|
||||||
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
assertThat(searchResults?.moderators).isEmpty()
|
||||||
|
assertThat(searchResults?.members).isEmpty()
|
||||||
|
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,15 +132,19 @@ class ChangeRolesPresenterTests {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||||
assertThat(initialResults).hasSize(10)
|
assertThat(initialResults?.members).hasSize(8)
|
||||||
|
assertThat(initialResults?.moderators).hasSize(1)
|
||||||
|
assertThat(initialResults?.admins).hasSize(1)
|
||||||
|
|
||||||
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
|
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
|
|
||||||
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
|
||||||
assertThat(searchResults).hasSize(1)
|
assertThat(searchResults?.admins).hasSize(1)
|
||||||
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
assertThat(searchResults?.moderators).isEmpty()
|
||||||
|
assertThat(searchResults?.members).isEmpty()
|
||||||
|
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,178 +37,192 @@ import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
|
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
import io.element.android.tests.testutils.clickOn
|
import io.element.android.tests.testutils.clickOn
|
||||||
import io.element.android.tests.testutils.pressBack
|
import io.element.android.tests.testutils.pressBack
|
||||||
|
import io.element.android.tests.testutils.pressBackKey
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.rules.TestRule
|
import org.junit.rules.TestRule
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ChangeRolesViewTest {
|
class ChangeRolesViewTest {
|
||||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `click on back icon search not active emits the expected event`() {
|
fun `passing a 'USER' role throws an exception`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val exception = runCatching {
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
eventSink = eventsRecorder,
|
role = RoomMember.Role.USER,
|
||||||
|
eventSink = EnsureNeverCalledWithParam(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.pressBack()
|
}.exceptionOrNull()
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
assertThat(exception).isNotNull()
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.Exit,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `click on back icon search active emits the expected event`() {
|
fun `back key - with search active toggles the search`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
isSearchActive = true,
|
isSearchActive = true,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.pressBack()
|
|
||||||
// This event should be there, maybe a problem with the SearchBar
|
rule.pressBackKey()
|
||||||
// It's working fine in the app, so let's ignore it for now
|
|
||||||
// eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
|
eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `click on search bar emits the expected event`() {
|
fun `back key - with search inactive exits the screen`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
|
isSearchActive = false,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rule.clickOn(CommonStrings.common_search_for_someone)
|
|
||||||
eventsRecorder.assertList(
|
rule.pressBackKey()
|
||||||
listOf(
|
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||||
// This event should be there, maybe a problem with the SearchBar
|
|
||||||
// It's working fine in the app, so let's ignore it for now
|
|
||||||
// ChangeRolesEvent.ToggleSearchActive,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `click on save button emits the expected event`() {
|
fun `back button - exits the screen`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
|
state = aChangeRolesState(
|
||||||
|
isSearchActive = false,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rule.pressBack()
|
||||||
|
|
||||||
|
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `save button - with changes, it saves them`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
hasPendingChanges = true,
|
hasPendingChanges = true,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
rule.clickOn(CommonStrings.action_save)
|
rule.clickOn(CommonStrings.action_save)
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.Save,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `testing exit confirmation dialog ok emits the expected event`() {
|
fun `save button - with no changes, does nothing`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
|
hasPendingChanges = false,
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
rule.clickOn(CommonStrings.action_save)
|
||||||
|
|
||||||
|
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged("")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exit confirmation dialog - submit exits the screen`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
|
rule.setChangeRolesContent(
|
||||||
|
state = aChangeRolesState(
|
||||||
|
isSearchActive = true,
|
||||||
exitState = AsyncAction.Confirming,
|
exitState = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
rule.clickOn(CommonStrings.action_ok)
|
rule.clickOn(CommonStrings.action_ok)
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.Exit,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `testing exit confirmation dialog cancel emits the expected event`() {
|
fun `exit confirmation dialog - cancel removes the dialog`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
|
isSearchActive = true,
|
||||||
exitState = AsyncAction.Confirming,
|
exitState = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.CancelExit
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `testing saving dialog failure OK emits the expected event`() {
|
fun `save confirmation dialog - submit saves the changes`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
|
||||||
savingState = AsyncAction.Failure(Exception("boom")),
|
|
||||||
eventSink = eventsRecorder,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
rule.clickOn(CommonStrings.action_ok)
|
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.ClearError,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `testing saving confirmation dialog for admin OK emits the expected event`() {
|
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
|
||||||
rule.setChangeRolesView(
|
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
role = RoomMember.Role.ADMIN,
|
role = RoomMember.Role.ADMIN,
|
||||||
|
isSearchActive = true,
|
||||||
savingState = AsyncAction.Confirming,
|
savingState = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
rule.clickOn(CommonStrings.action_ok)
|
rule.clickOn(CommonStrings.action_ok)
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
|
||||||
ChangeRolesEvent.Save,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `testing saving confirmation dialog for admin cancel emits the expected event`() {
|
fun `save confirmation dialog - cancel removes the dialog`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesState(
|
state = aChangeRolesState(
|
||||||
role = RoomMember.Role.ADMIN,
|
role = RoomMember.Role.ADMIN,
|
||||||
|
isSearchActive = true,
|
||||||
savingState = AsyncAction.Confirming,
|
savingState = AsyncAction.Confirming,
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
rule.clickOn(CommonStrings.action_cancel)
|
rule.clickOn(CommonStrings.action_cancel)
|
||||||
eventsRecorder.assertList(
|
|
||||||
listOf(
|
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||||
ChangeRolesEvent.QueryChanged(""),
|
}
|
||||||
ChangeRolesEvent.ClearError,
|
|
||||||
)
|
@Test
|
||||||
|
fun `error dialog - dismissing removes the dialog`() {
|
||||||
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
|
rule.setChangeRolesContent(
|
||||||
|
state = aChangeRolesState(
|
||||||
|
isSearchActive = true,
|
||||||
|
savingState = AsyncAction.Failure(IllegalStateException("boom")),
|
||||||
|
eventSink = eventsRecorder,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rule.clickOn(CommonStrings.action_ok)
|
||||||
|
|
||||||
|
eventsRecorder.assertSingle(ChangeRolesEvent.ClearError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -217,7 +231,7 @@ class ChangeRolesViewTest {
|
||||||
val selectedUsers = aMatrixUserList().take(2)
|
val selectedUsers = aMatrixUserList().take(2)
|
||||||
val userToDeselect = selectedUsers[1]
|
val userToDeselect = selectedUsers[1]
|
||||||
assertThat(userToDeselect.displayName).isEqualTo("Bob")
|
assertThat(userToDeselect.displayName).isEqualTo("Bob")
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = aChangeRolesStateWithSelectedUsers().copy(
|
state = aChangeRolesStateWithSelectedUsers().copy(
|
||||||
selectedUsers = selectedUsers.toImmutableList(),
|
selectedUsers = selectedUsers.toImmutableList(),
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
|
|
@ -235,6 +249,7 @@ class ChangeRolesViewTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Config(qualifiers = "h1000dp")
|
||||||
fun `testing adding user to the selected list emits the expected event`() {
|
fun `testing adding user to the selected list emits the expected event`() {
|
||||||
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
|
||||||
val selectedUsers = aMatrixUserList().take(2)
|
val selectedUsers = aMatrixUserList().take(2)
|
||||||
|
|
@ -242,12 +257,12 @@ class ChangeRolesViewTest {
|
||||||
selectedUsers = selectedUsers.toImmutableList(),
|
selectedUsers = selectedUsers.toImmutableList(),
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
)
|
)
|
||||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results[2].toMatrixUser()
|
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.members.first().toMatrixUser()
|
||||||
assertThat(userToSelect.displayName).isEqualTo("Carol")
|
assertThat(userToSelect.displayName).isEqualTo("Carol")
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = state,
|
state = state,
|
||||||
)
|
)
|
||||||
// Select the user from the rom list
|
// Select the user from the row list
|
||||||
rule.onNodeWithText("Carol").performClick()
|
rule.onNodeWithText("Carol").performClick()
|
||||||
eventsRecorder.assertList(
|
eventsRecorder.assertList(
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -265,9 +280,9 @@ class ChangeRolesViewTest {
|
||||||
selectedUsers = selectedUsers.toImmutableList(),
|
selectedUsers = selectedUsers.toImmutableList(),
|
||||||
eventSink = eventsRecorder,
|
eventSink = eventsRecorder,
|
||||||
)
|
)
|
||||||
val userToSelect = (state.searchResults as SearchBarResultState.Results).results[1].toMatrixUser()
|
val userToSelect = (state.searchResults as SearchBarResultState.Results).results.moderators.first().toMatrixUser()
|
||||||
assertThat(userToSelect.displayName).isEqualTo("Bob")
|
assertThat(userToSelect.displayName).isEqualTo("Bob")
|
||||||
rule.setChangeRolesView(
|
rule.setChangeRolesContent(
|
||||||
state = state,
|
state = state,
|
||||||
)
|
)
|
||||||
// Select the user from the rom list
|
// Select the user from the rom list
|
||||||
|
|
@ -279,16 +294,16 @@ class ChangeRolesViewTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesView(
|
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
|
||||||
state: ChangeRolesState,
|
state: ChangeRolesState,
|
||||||
onBackPressed: () -> Unit = EnsureNeverCalled(),
|
onBackPressed: () -> Unit = EnsureNeverCalled(),
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
ChangeRolesView(
|
ChangeRolesView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackPressed = onBackPressed,
|
navigateUp = onBackPressed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 New Vector Ltd
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.roomdetails.rolesandpermissions.changeroles
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.MembersByRole
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
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_3
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_4
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_5
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MembersByRoleTest {
|
||||||
|
@Test
|
||||||
|
fun `constructor - with single member list categorizes and sorts members`() {
|
||||||
|
val members = listOf(
|
||||||
|
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
|
||||||
|
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
|
||||||
|
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
|
||||||
|
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
|
||||||
|
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
|
||||||
|
)
|
||||||
|
val membersByRole = MembersByRole(members = members)
|
||||||
|
assertThat(membersByRole.admins).containsExactly(
|
||||||
|
aRoomMember(A_USER_ID, displayName = "Alice", role = RoomMember.Role.ADMIN),
|
||||||
|
aRoomMember(A_USER_ID_2, displayName = "Bob", role = RoomMember.Role.ADMIN),
|
||||||
|
)
|
||||||
|
assertThat(membersByRole.moderators).isEmpty()
|
||||||
|
assertThat(membersByRole.members).containsExactly(
|
||||||
|
aRoomMember(A_USER_ID_3, displayName = "Carol", role = RoomMember.Role.USER),
|
||||||
|
aRoomMember(A_USER_ID_4, displayName = "David", role = RoomMember.Role.USER),
|
||||||
|
aRoomMember(A_USER_ID_5, displayName = "Eve", role = RoomMember.Role.USER),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEmpty - only returns true with no members of any role`() {
|
||||||
|
val emptyMembersByRole = MembersByRole(emptyList())
|
||||||
|
assertThat(emptyMembersByRole.isEmpty()).isTrue()
|
||||||
|
|
||||||
|
val membersByRoleWithAdmins = MembersByRole(
|
||||||
|
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.ADMIN)),
|
||||||
|
moderators = persistentListOf(),
|
||||||
|
members = persistentListOf(),
|
||||||
|
)
|
||||||
|
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
|
||||||
|
|
||||||
|
val membersByRoleWithModerators = MembersByRole(
|
||||||
|
admins = persistentListOf(),
|
||||||
|
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.MODERATOR)),
|
||||||
|
members = persistentListOf(),
|
||||||
|
)
|
||||||
|
assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
|
||||||
|
|
||||||
|
val membersByRoleWithMembers = MembersByRole(
|
||||||
|
admins = persistentListOf(),
|
||||||
|
moderators = persistentListOf(),
|
||||||
|
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.USER)),
|
||||||
|
)
|
||||||
|
assertThat(membersByRoleWithMembers.isEmpty()).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"In den Chat-Einstellungen kannst du einen Chat als Favorit hinzufügen.
|
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"In den Chat-Einstellungen kannst du einen Chat als Favorit hinzufügen.
|
||||||
Um deine anderen Chats zu sehen wähle diesen Filter ab."</string>
|
Um deine anderen Chats zu sehen wähle diesen Filter ab."</string>
|
||||||
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du hast noch keine Chats als Favorit markiert."</string>
|
<string name="screen_roomlist_filter_favourites_empty_state_title">"Du hast noch keine Chats als Favorit markiert."</string>
|
||||||
|
<string name="screen_roomlist_filter_invites">"Einladungen"</string>
|
||||||
|
<string name="screen_roomlist_filter_invites_empty_state_title">"Du hast keine ausstehenden Einladungen."</string>
|
||||||
<string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string>
|
<string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Wähle Filter ab, um Deine Chats zu sehen."</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Wähle Filter ab, um Deine Chats zu sehen."</string>
|
||||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du hast keine Chats für diese Auswahl"</string>
|
<string name="screen_roomlist_filter_mixed_empty_state_title">"Du hast keine Chats für diese Auswahl"</string>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@ internal fun RecoveryKeyView(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = CommonStrings.common_recovery_key),
|
text = when (state.recoveryKeyUserStory) {
|
||||||
|
RecoveryKeyUserStory.Enter -> stringResource(R.string.screen_recovery_key_confirm_key_label)
|
||||||
|
else -> stringResource(id = CommonStrings.common_recovery_key)
|
||||||
|
},
|
||||||
modifier = Modifier.padding(start = 16.dp),
|
modifier = Modifier.padding(start = 16.dp),
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@
|
||||||
<string name="screen_recovery_key_confirm_error_content">"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
|
<string name="screen_recovery_key_confirm_error_content">"Bitte versuche es noch einmal, um den Zugriff auf dein Chat-Backup zu bestätigen."</string>
|
||||||
<string name="screen_recovery_key_confirm_error_title">"Falscher Wiederherstellungsschlüssel"</string>
|
<string name="screen_recovery_key_confirm_error_title">"Falscher Wiederherstellungsschlüssel"</string>
|
||||||
<string name="screen_recovery_key_confirm_key_description">"Dies funktioniert auch mit einer Wiederherstellungspassphrase oder einer geheime Passphrase/einem geheimen Schlüssel."</string>
|
<string name="screen_recovery_key_confirm_key_description">"Dies funktioniert auch mit einer Wiederherstellungspassphrase oder einer geheime Passphrase/einem geheimen Schlüssel."</string>
|
||||||
|
<string name="screen_recovery_key_confirm_key_label">
|
||||||
|
<b>"Wiederherstellungsschlüssel"</b>
|
||||||
|
" oder Passcode"
|
||||||
|
</string>
|
||||||
<string name="screen_recovery_key_confirm_key_placeholder">"Eingeben…"</string>
|
<string name="screen_recovery_key_confirm_key_placeholder">"Eingeben…"</string>
|
||||||
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</string>
|
<string name="screen_recovery_key_confirm_success">"Wiederherstellungsschlüssel bestätigt"</string>
|
||||||
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel oder Passcode bestätigen"</string>
|
<string name="screen_recovery_key_confirm_title">"Wiederherstellungsschlüssel oder Passcode bestätigen"</string>
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,13 @@ import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||||
|
|
@ -164,7 +169,11 @@ class RustMatrixRoom(
|
||||||
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
|
override val syncUpdateFlow: StateFlow<Long> = _syncUpdateFlow.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
timeline.membershipChangeEventReceived
|
val powerLevelChanges = roomInfoFlow.map { it.userPowerLevels }.distinctUntilChanged()
|
||||||
|
val membershipChanges = timeline.membershipChangeEventReceived.onStart { emit(Unit) }
|
||||||
|
combine(membershipChanges, powerLevelChanges) { _, _ -> }
|
||||||
|
// Skip initial one
|
||||||
|
.drop(1)
|
||||||
// The new events should already be in the SDK cache, no need to fetch them from the server
|
// The new events should already be in the SDK cache, no need to fetch them from the server
|
||||||
.onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
|
.onEach { roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
|
||||||
.launchIn(roomCoroutineScope)
|
.launchIn(roomCoroutineScope)
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,9 @@
|
||||||
<string name="common_privacy_policy">"Datenschutzerklärung"</string>
|
<string name="common_privacy_policy">"Datenschutzerklärung"</string>
|
||||||
<string name="common_reaction">"Reaktion"</string>
|
<string name="common_reaction">"Reaktion"</string>
|
||||||
<string name="common_reactions">"Reaktionen"</string>
|
<string name="common_reactions">"Reaktionen"</string>
|
||||||
<string name="common_recovery_key">"Wiederherstellungsschlüssel oder Passcode"</string>
|
<string name="common_recovery_key">
|
||||||
|
<b>"Wiederherstellungsschlüssel"</b>
|
||||||
|
</string>
|
||||||
<string name="common_refreshing">"Wird erneuert…"</string>
|
<string name="common_refreshing">"Wird erneuert…"</string>
|
||||||
<string name="common_replying_to">"%1$s antworten"</string>
|
<string name="common_replying_to">"%1$s antworten"</string>
|
||||||
<string name="common_report_a_bug">"Einen Fehler melden"</string>
|
<string name="common_report_a_bug">"Einen Fehler melden"</string>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3cf713ae66135179f17b52578bf4663bf34a102b92c0809c42e5285856d43f3b
|
oid sha256:f9eb707bd20836dd80d1939a1eb405bbeca4b58aa36e9b627f3775cdffc9dcc0
|
||||||
size 18257
|
size 15142
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7737c502737b811720b54ba8e8db4248df8e0e8577435c61270853f92e65f6bc
|
oid sha256:56873f51dd8de6630f5423f796687049a920bddafb5af17a47e4f901a5d9f360
|
||||||
size 19731
|
size 71767
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1b0ca2b91bc525254ed4be526e622608f77d809a1c097ef02943d4cd751c65b2
|
oid sha256:34efa87358c4529d0f67729fd417a3d52dc5a0031192dbf123bcc667d6e78391
|
||||||
size 55941
|
size 56803
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e96824d27d0fda0b183e8c90d659406d012861ff520f81cfa60374424137bbdb
|
oid sha256:4818ed45a985359112ee92c38148f1030647ead270727525fed1cf1f5c493180
|
||||||
size 66079
|
size 66293
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b00d18ba4fa132e32c6580682c6b78d754d330735afcf8e4242ee0def213ba44
|
oid sha256:57b0cd541704cd82962e03d2173bb37350d2f91bf1f3d6121314bdccad50c8e6
|
||||||
size 66046
|
size 66255
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:eeabb0a4ed645c7f47da62e7bafaca54ab1d3c5c48a195b48c3f807e5137d39c
|
oid sha256:ebd6484e81803758298b4ff0e19a042918d85c29e3828a561c83328a9b3e4d73
|
||||||
size 59992
|
size 60644
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5199d1491a315d7653b5e09049ead901650332deb50ec4d24742ac7f3f7ff535
|
oid sha256:9e07c6ee5a1e9ebedc98ed8daca1c7e81c877102e24d9cc7a21834ce24df7d1d
|
||||||
size 15618
|
size 16758
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:54461c606debb6867bd48a054f551dd56e2895530f06e6890b44ad7b1eeb9219
|
oid sha256:77bca4e3380c63dba4e7def0937770c6d5b1ae5bde61e4743ecde035431fb8be
|
||||||
size 65289
|
size 64625
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:66707e11931cc5824142a5f42ed95c4becd5621ea3a9c9cac3df5f0435b3883b
|
oid sha256:b265ee7f4eb6c56e531004af6daae9ad89167b027d089fa960fc17553ec80131
|
||||||
size 66191
|
size 66783
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:31dcb5cef9f857e2b96631b4dd23a59a200c9174f8cc0e406d2a859497a8f893
|
oid sha256:eb629e088198030a3a9a09c8cfc815a87e831dc6f5e26b225d17c61883585e50
|
||||||
size 58934
|
size 58902
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:56e0b337fc6afdadf477785df479478ae24dd7c9cb1f98bedf33b920ad374e78
|
oid sha256:f4dd6cbcd17c5c97c240a1244f3233f0f228612b317cc8f6b3f39a0d0d2b05fd
|
||||||
size 69899
|
size 70120
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5a99d564c528dbcc2080b6f83ca09a97bd518412019d0f32b76b80086af4f17e
|
oid sha256:4e47b753ec51c5cdc4640b9883bd9bbcb68af0c0ceacbc31020132213fb9bfbc
|
||||||
size 16966
|
size 14223
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:eb3c08d58fd0dfa60ad1549b26a53f02239f1ca43aeda9e52350952d86d5fa74
|
oid sha256:9d1cf36a65fc7e916a15a0a6eab4e5eaba47f8df5d39d5cb9fe75b137138a96d
|
||||||
size 18178
|
size 69609
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f86fad7b780fb7fc0b36377724c6830d86c5d41dcfd50f8de8cbe9f40473e68d
|
oid sha256:fa0b65d6cd2eac2746a2ba1598f075a6d5d144cfc62fbde8e7e17c5b69d85f1c
|
||||||
size 52719
|
size 53497
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:00c4dba57e32a87d6acde138625214bb795c7e7c8987561d6ddd53fbf26674b5
|
oid sha256:417ca17376f6e296a8955390c800191ebdccf8b358dbc738f438b70c2568606e
|
||||||
size 64446
|
size 64439
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c3e270adac07c800c5ab1a124d564beee96b2e39e9473a69b8b90ff647bfd6df
|
oid sha256:a73ae445a25011b1c376f93b7e7203b023e23a027aa2a44d109008a1cca98406
|
||||||
size 64184
|
size 64193
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6490597e92bd937451dbcf7d516f43525b5d18835cefec4f825fdf90ee6dc626
|
oid sha256:a98762c06334da0cc53d04826623850b15d3fc5fdf7cdf8f50f896574a048d0d
|
||||||
size 57891
|
size 58356
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5e088b944b00b462fcbaa16033a786835b8a09dd26f8a9b946580a237d2b363e
|
oid sha256:b8188aab8aaa9d7685e6936883e635f097bc3b9b8fdd5bb778bc7baae3f97ea1
|
||||||
size 15064
|
size 16308
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:afac0f9958320d5c1f12d9b6647a15608eff51627dcb82d35ec9524a579f2637
|
oid sha256:b6a02199d5c7b5bf4ff0251d437f071e2ce54440b1e0b4519305e0080f7ed6ec
|
||||||
size 61111
|
size 60375
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3991def6f61df49c73e025a417680f1bd409d8e2aff41ba2434e3a5a50914665
|
oid sha256:5898156b9c4ce053ab24728b0679bf6d2d0873459a7b1e4951f8f9ce646e4074
|
||||||
size 62128
|
size 62587
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:af3c14da68316feb82b4ae905868116239e43cb8b0e5393246efe0edd61f1bc2
|
oid sha256:6648e7dc5c97e27efbf347380fc3ae3c67866595b086e1adefe36a274bf3afc2
|
||||||
size 56497
|
size 56314
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1979b5b0dff66a05765a1ecd03d9a896d2aaf6dffc686ffd27bba3d5a0ac927a
|
oid sha256:5d984c253b4990220acebfe91d33fb7f4201fb7a976bea48b2bc04e4788ddee2
|
||||||
size 67254
|
size 67263
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:31deb74551ce3e14d7fdbb6fad4bd1dd9c4124d50a39dcef572c1092eac75561
|
||||||
|
size 16580
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e091e28939405c49558f40dbd291ace6280152b84e10676188b51f0ddc5a662d
|
||||||
|
size 16104
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0a9321b5a4d15d815a690d422652d641deb24c06d49e99c5b28d3975544ec256
|
oid sha256:bd35705e28bd0b46d2ca8752c3f338c883d5bc2e8c78af26c35eda435a6cf8cf
|
||||||
size 40446
|
size 42542
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:644e4ac9a9cdaf68cf3fe8b0d39fd46435d2074c384dbc68638703a6242af65d
|
oid sha256:5bf4f3ff7ed970ba7eef2119525e800f80bfc056ccbd5345c1c610257180b50d
|
||||||
size 52111
|
size 53908
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b48d49a2c89c827ba3041738a01c4a665f694d904a1a4c8ad1aa6fbfcdc624de
|
oid sha256:f963d37ac30795df5de921cef04b17de6baf45160716533984d08f6e543fb1e5
|
||||||
size 50508
|
size 52566
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1f0d40a93506c47240496c4fc7a1883aa69a964eb754f33c24b14cf877bc83cf
|
oid sha256:1d1cd406230b94ba11e414aedfbe49d903d7aa54791c4dbd93616a9f16ca7068
|
||||||
size 38240
|
size 39917
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3556be4af59bd9815b6d8e57b1e7fb8b8e7985afe94cbf745e004f19373590b3
|
oid sha256:7f9dbe41267a197887860cda4cef70138fd1e1b79497f396d1535725bc6219f4
|
||||||
size 48525
|
size 50262
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cadda348b54958eb917c64ae03770c622b503393d2db9ae0af9e3abe9ee31d15
|
oid sha256:02ebc69b55046c946142bac74f299d191b4dba2e175a670a67ed1e91dcaf51a8
|
||||||
size 47066
|
size 48753
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e643a714d2c527aa31ca716b3f4d9cc2423ad07b3615bbc274755e2ff90a73e4
|
oid sha256:2c4aeacb175ef566e8093a9d7e297640739c71e488f0fe56f75803c2b98b09d2
|
||||||
size 29035
|
size 31137
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4d28f1cb062b500452b353f35e8889a260e946807d8f1b5a04f5846ac4ad6482
|
oid sha256:e7837d958cae6c7627a57a27880a00747dcebea7f00a8a29e4a0dcf67b5fae47
|
||||||
size 27511
|
size 29014
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:05ee5bc12b1766b9aff8a1c0352e7f4cf2144487672add3fdb9d4c1d0f0b3c0f
|
oid sha256:e7731621308a3392d0b940ce881171e909852d24787b63fbdbfea69e238c9f18
|
||||||
size 18954
|
size 20983
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a6675dc88dabfc68848505be1115d25c34243b2bef0c11443c5bee1ae4b178c
|
oid sha256:13e9687d249fd366e7d39cb20a49664ae661453f4a4333c3eb6334065211aa58
|
||||||
size 18760
|
size 20794
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bfe63f8ce9fcaeb7047cf0ae5e843151983f2f6edb10fff502c11589007c31b3
|
oid sha256:ec7487af11bc617f04296a895351dd82e79a9acd2272225d2cf84a735a5bb30b
|
||||||
size 27747
|
size 29005
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b6b4f5b67afae571b66cc0ad1bcdc5bca1c3f9903152897816011b8a7c87828e
|
oid sha256:4120ab56b29285d4e2576563c521f59541d4368b9adcdc6c0dd997046df8a385
|
||||||
size 26009
|
size 27504
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:23c85dd33bd5f865685545b2fa4682081eaff194da333a6801b46011d78a168a
|
oid sha256:910d080a5abbba99a1347c77d70df982fe520239700cfbd04af460d33f5a0037
|
||||||
size 18313
|
size 20043
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b8159ab16ad6e5ac1cdd4a604455084814369af2dc72e4e506eaf81799c234bc
|
oid sha256:a77b0bb03a6941cf821420266c4922f9ef88c775ee6f8771aa17e966b3d86584
|
||||||
size 18030
|
size 19769
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue