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 e01af9033b
commit 4057b4b9cd
66 changed files with 542 additions and 241 deletions

1
changelog.d/2593.misc Normal file
View file

@ -0,0 +1 @@
Categorise members by role in change roles screen.

View file

@ -65,7 +65,7 @@ class ChangeRolesNode @AssistedInject constructor(
ChangeRolesView( ChangeRolesView(
modifier = modifier, modifier = modifier,
state = state, 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 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()
} }

View file

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

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.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") },
) )

View file

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

View file

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

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.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,
) )
} }
}
} }

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

View file

@ -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,
) )

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

View file

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

View file

@ -175,7 +175,9 @@
<string name="common_privacy_policy">"Datenschutz­erklärung"</string> <string name="common_privacy_policy">"Datenschutz­erklä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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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