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:
Jorge Martin Espinosa 2024-04-08 10:54:38 +02:00 committed by GitHub
parent 192a1d2107
commit 8e2f7a35cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 542 additions and 241 deletions

View file

@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
ChangeRolesView(
modifier = modifier,
state = state,
onBackPressed = this::navigateUp,
navigateUp = this::navigateUp,
)
}
}

View file

@ -75,7 +75,7 @@ class ChangeRolesPresenter @AssistedInject constructor(
var query by rememberSaveable { mutableStateOf<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
mutableStateOf<SearchBarResultState<MembersByRole>>(SearchBarResultState.Initial())
}
val selectedUsers = remember {
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
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
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()
}
.launchIn(this)
@ -103,8 +103,9 @@ class ChangeRolesPresenter @AssistedInject constructor(
LaunchedEffect(query, roomMemberState) {
val results = dataSource
.search(query.orEmpty())
.sorted()
.groupedByRole()
println(results)
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} 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> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}

View file

@ -16,18 +16,20 @@
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.designsystem.theme.components.SearchBarResultState
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.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
data class ChangeRolesState(
val role: RoomMember.Role,
val query: String?,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
val searchResults: SearchBarResultState<MembersByRole>,
val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>,
@ -35,3 +37,21 @@ data class ChangeRolesState(
val canChangeMemberRole: (UserId) -> Boolean,
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()
}

View file

@ -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.matrix.api.core.UserId
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.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
@ -32,7 +33,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
get() = sequenceOf(
aChangeRolesState(),
aChangeRolesState(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
@ -41,7 +42,7 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy(
query = "Alice",
isSearchActive = true,
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
@ -56,7 +57,7 @@ internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
query: String? = null,
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
searchResults: SearchBarResultState<MembersByRole> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
@ -78,7 +79,17 @@ internal fun aChangeRolesState(
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
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,
canRemoveMember = { it != UserId("@alice:server.org") },
)

View file

@ -26,8 +26,10 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
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.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
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.AsyncIndicatorHost
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.dialogs.ConfirmationDialog
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.matrix.api.core.UserId
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.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.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRolesView(
state: ChangeRolesState,
onBackPressed: () -> Unit,
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp)
BackHandler(enabled = !state.isSearchActive) {
state.eventSink(ChangeRolesEvent.Exit)
}
@ -136,7 +145,7 @@ fun ChangeRolesView(
resultState = state.searchResults,
) { members ->
SearchResultsList(
isSearchActive = true,
currentRole = state.role,
lazyListState = lazyListState,
searchResults = members,
selectedUsers = state.selectedUsers,
@ -152,9 +161,9 @@ fun ChangeRolesView(
) {
Column {
SearchResultsList(
isSearchActive = false,
currentRole = state.role,
lazyListState = lazyListState,
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()),
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },
@ -179,7 +188,7 @@ fun ChangeRolesView(
AsyncActionView(
async = state.exitState,
onSuccess = { updatedOnBackPressed() },
onSuccess = { updatedNavigateUp() },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
@ -227,8 +236,8 @@ fun ChangeRolesView(
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultsList(
isSearchActive: Boolean,
searchResults: ImmutableList<RoomMember>,
currentRole: RoomMember.Role,
searchResults: MembersByRole,
selectedUsers: ImmutableList<MatrixUser>,
canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit,
@ -241,43 +250,145 @@ private fun SearchResultsList(
item {
selectedUsersList(selectedUsers)
}
stickyHeader {
val textResId = if (isSearchActive) {
CommonStrings.common_search_results
} else {
R.string.screen_room_member_list_room_members_header_title
}
Text(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
text = stringResource(textResId),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
items(searchResults, key = { it.userId }) { roomMember ->
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
{
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onSelectionToggled(roomMember) },
if (searchResults.admins.isNotEmpty()) {
stickyHeader { ListSectionHeader(text = stringResource(R.string.screen_room_roles_and_permissions_admins)) }
// Add a footer for the admin section in change role to moderator screen
if (currentRole == RoomMember.Role.MODERATOR) {
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,
)
}
} else {
null
}
MatrixUserRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
matrixUser = MatrixUser(
userId = roomMember.userId,
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
),
trailingContent = trailingContent,
)
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(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
text = text,
style = ElementTheme.typography.fontBodyLgMedium,
)
}
@Composable
private fun ListMemberItem(
roomMember: RoomMember,
canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit,
selectedUsers: ImmutableList<MatrixUser>,
) {
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit) = {
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onSelectionToggled(roomMember) },
enabled = canToggle,
)
}
MemberRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem),
name = roomMember.getBestName(),
userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true },
isPending = roomMember.membership == RoomMembershipState.INVITE,
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()
}
}
@ -287,7 +398,27 @@ internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::
ElementPreview {
ChangeRolesView(
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,
)
}
)
}
}

View file

@ -106,15 +106,19 @@ class ChangeRolesPresenterTests {
presenter.present()
}.test {
val initialState = awaitItem()
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
assertThat(initialResults).hasSize(10)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8)
assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1)
initialState.eventSink(ChangeRolesEvent.QueryChanged("Alice"))
skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
assertThat(searchResults).hasSize(1)
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(searchResults?.admins).hasSize(1)
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()
}.test {
skipItems(1)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
assertThat(initialResults).hasSize(10)
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(initialResults?.members).hasSize(8)
assertThat(initialResults?.moderators).hasSize(1)
assertThat(initialResults?.admins).hasSize(1)
room.givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList().take(1).toPersistentList()))
skipItems(1)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
assertThat(searchResults).hasSize(1)
assertThat(searchResults.firstOrNull()?.userId).isEqualTo(A_USER_ID)
val searchResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results
assertThat(searchResults?.admins).hasSize(1)
assertThat(searchResults?.moderators).isEmpty()
assertThat(searchResults?.members).isEmpty()
assertThat(searchResults?.admins?.firstOrNull()?.userId).isEqualTo(A_USER_ID)
}
}

View file

@ -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.ui.strings.CommonStrings
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.clickOn
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import kotlinx.collections.immutable.toImmutableList
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.lang.IllegalStateException
@RunWith(AndroidJUnit4::class)
class ChangeRolesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `click on back icon search not active emits the expected event`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesView(
state = aChangeRolesState(
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.Exit,
fun `passing a 'USER' role throws an exception`() {
val exception = runCatching {
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.USER,
eventSink = EnsureNeverCalledWithParam(),
),
)
)
}.exceptionOrNull()
assertThat(exception).isNotNull()
}
@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>()
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = aChangeRolesState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
rule.pressBack()
// 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
// eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
rule.pressBackKey()
eventsRecorder.assertSingle(ChangeRolesEvent.ToggleSearchActive)
}
@Test
fun `click on search bar emits the expected event`() {
fun `back key - with search inactive exits the screen`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = aChangeRolesState(
isSearchActive = false,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.common_search_for_someone)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
// 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,
)
)
rule.pressBackKey()
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Exit))
}
@Test
fun `click on save button emits the expected event`() {
fun `back button - exits the screen`() {
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(
hasPendingChanges = true,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.Save,
)
)
eventsRecorder.assertList(listOf(ChangeRolesEvent.QueryChanged(""), ChangeRolesEvent.Save))
}
@Test
fun `testing exit confirmation dialog ok emits the expected event`() {
fun `save button - with no changes, does nothing`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesView(
rule.setChangeRolesContent(
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,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.Exit,
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.Exit)
}
@Test
fun `testing exit confirmation dialog cancel emits the expected event`() {
fun `exit confirmation dialog - cancel removes the dialog`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = aChangeRolesState(
isSearchActive = true,
exitState = AsyncAction.Confirming,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.CancelExit
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit)
}
@Test
fun `testing saving dialog failure OK emits the expected event`() {
fun `save confirmation dialog - submit saves the changes`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
rule.setChangeRolesView(
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(
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
isSearchActive = true,
savingState = AsyncAction.Confirming,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.Save,
)
)
eventsRecorder.assertSingle(ChangeRolesEvent.Save)
}
@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>()
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = aChangeRolesState(
role = RoomMember.Role.ADMIN,
isSearchActive = true,
savingState = AsyncAction.Confirming,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList(
listOf(
ChangeRolesEvent.QueryChanged(""),
ChangeRolesEvent.ClearError,
)
eventsRecorder.assertSingle(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
@ -217,7 +231,7 @@ class ChangeRolesViewTest {
val selectedUsers = aMatrixUserList().take(2)
val userToDeselect = selectedUsers[1]
assertThat(userToDeselect.displayName).isEqualTo("Bob")
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = aChangeRolesStateWithSelectedUsers().copy(
selectedUsers = selectedUsers.toImmutableList(),
eventSink = eventsRecorder,
@ -235,6 +249,7 @@ class ChangeRolesViewTest {
}
@Test
@Config(qualifiers = "h1000dp")
fun `testing adding user to the selected list emits the expected event`() {
val eventsRecorder = EventsRecorder<ChangeRolesEvent>()
val selectedUsers = aMatrixUserList().take(2)
@ -242,12 +257,12 @@ class ChangeRolesViewTest {
selectedUsers = selectedUsers.toImmutableList(),
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")
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = state,
)
// Select the user from the rom list
// Select the user from the row list
rule.onNodeWithText("Carol").performClick()
eventsRecorder.assertList(
listOf(
@ -265,9 +280,9 @@ class ChangeRolesViewTest {
selectedUsers = selectedUsers.toImmutableList(),
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")
rule.setChangeRolesView(
rule.setChangeRolesContent(
state = state,
)
// Select the user from the rom list
@ -279,16 +294,16 @@ class ChangeRolesViewTest {
)
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesView(
state: ChangeRolesState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ChangeRolesView(
state = state,
onBackPressed = onBackPressed,
)
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setChangeRolesContent(
state: ChangeRolesState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ChangeRolesView(
state = state,
navigateUp = onBackPressed,
)
}
}
}

View file

@ -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()
}
}

View file

@ -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.
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_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_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>

View file

@ -70,7 +70,10 @@ internal fun RecoveryKeyView(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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),
style = ElementTheme.typography.fontBodyMdRegular,
)

View file

@ -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_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_label">
<b>"Wiederherstellungsschlüssel"</b>
" oder Passcode"
</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_title">"Wiederherstellungsschlüssel oder Passcode bestätigen"</string>