Room admins can change user roles (#2423)
Allow Admins to modify room member roles: - Add a 'roles and permissions' option for each room. - Allow promoting users to admins, adding or removing moderators, and demote yourself if you're and admin. --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
committed by
GitHub
parent
b64d7a267e
commit
6a75be7bf0
1
changelog.d/2257.feature
Normal file
1
changelog.d/2257.feature
Normal file
@@ -0,0 +1 @@
|
||||
Admins can now change user roles in rooms.
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.createroom.impl.addpeople
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.userlist.SelectionMode
|
||||
import io.element.android.features.createroom.impl.userlist.UserListState
|
||||
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
|
||||
import io.element.android.features.createroom.impl.userlist.aUserListState
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
@@ -32,7 +31,7 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
||||
aUserListState(),
|
||||
aUserListState().copy(
|
||||
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = false,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
),
|
||||
@@ -44,7 +43,7 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
|
||||
}
|
||||
.toImmutableList()
|
||||
),
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
isSearchActive = true,
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -92,7 +92,7 @@ fun SearchUserBar(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
|
||||
)
|
||||
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
selectedUsers = selectedUsers,
|
||||
autoScroll = true,
|
||||
@@ -114,7 +114,7 @@ fun SearchUserBar(
|
||||
SearchMultipleUsersResultItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
searchResult = searchResult,
|
||||
isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
|
||||
isUserSelected = selectedUsers.contains(searchResult.matrixUser),
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) {
|
||||
onUserSelected(searchResult.matrixUser)
|
||||
|
||||
@@ -29,7 +29,7 @@ import io.element.android.features.createroom.impl.userlist.UserListStateProvide
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
|
||||
@Composable
|
||||
fun UserListView(
|
||||
@@ -64,7 +64,7 @@ fun UserListView(
|
||||
)
|
||||
|
||||
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
selectedUsers = state.selectedUsers,
|
||||
autoScroll = true,
|
||||
|
||||
@@ -18,10 +18,11 @@ package io.element.android.features.createroom.impl.configureroom
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.createroom.impl.CreateRoomConfig
|
||||
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
|
||||
override val values: Sequence<ConfigureRoomState>
|
||||
@@ -31,7 +32,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
|
||||
config = CreateRoomConfig(
|
||||
roomName = "Room 101",
|
||||
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldn’t be more than 3 lines",
|
||||
invites = aListOfSelectedUsers(),
|
||||
invites = aMatrixUserList().toImmutableList(),
|
||||
privacy = RoomPrivacy.Public,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -59,7 +59,7 @@ 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.RoomId
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -120,7 +120,7 @@ fun ConfigureRoomView(
|
||||
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
|
||||
)
|
||||
if (state.config.invites.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||
selectedUsers = state.config.invites,
|
||||
|
||||
@@ -25,7 +25,7 @@ class UserListDataStore @Inject constructor() {
|
||||
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
|
||||
|
||||
fun selectUser(user: MatrixUser) {
|
||||
if (user !in selectedUsers.value) {
|
||||
if (!selectedUsers.value.contains(user)) {
|
||||
selectedUsers.tryEmit(selectedUsers.value.plus(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package io.element.android.features.createroom.impl.userlist
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import io.element.android.libraries.usersearch.api.UserSearchResult
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@@ -38,15 +37,15 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
|
||||
aUserListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfSelectedUsers()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
isSearchActive = true,
|
||||
searchQuery = "@someone:matrix.org",
|
||||
selectionMode = SelectionMode.Multiple,
|
||||
selectedUsers = aListOfSelectedUsers(),
|
||||
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aListOfSelectedUsers()),
|
||||
),
|
||||
aUserListState().copy(
|
||||
isSearchActive = true,
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
|
||||
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
|
||||
<string name="screen_room_encrypted_history_banner">"Message history is currently unavailable."</string>
|
||||
<string name="screen_room_encrypted_history_banner_unverified">"Message history is unavailable in this room. Verify this device to see your message history."</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
@@ -77,6 +79,9 @@
|
||||
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
|
||||
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
|
||||
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
|
||||
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
|
||||
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
|
||||
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
|
||||
|
||||
@@ -36,6 +36,7 @@ import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||
import io.element.android.features.roomdetails.impl.members.details.avatar.AvatarPreviewNode
|
||||
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
@@ -91,6 +92,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data object PollHistory : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AdminSettings : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -120,6 +124,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
override fun openPollHistory() {
|
||||
backstack.push(NavTarget.PollHistory)
|
||||
}
|
||||
|
||||
override fun openAdminSettings() {
|
||||
backstack.push(NavTarget.AdminSettings)
|
||||
}
|
||||
}
|
||||
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
|
||||
}
|
||||
@@ -189,6 +197,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
is NavTarget.PollHistory -> {
|
||||
pollHistoryEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
|
||||
is NavTarget.AdminSettings -> {
|
||||
createNode<RolesAndPermissionsFlowNode>(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
fun openRoomNotificationSettings()
|
||||
fun openAvatarPreview(name: String, url: String)
|
||||
fun openPollHistory()
|
||||
fun openAdminSettings()
|
||||
}
|
||||
|
||||
private val callbacks = plugins<Callback>()
|
||||
@@ -119,6 +120,10 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
callbacks.forEach { it.openAvatarPreview(name, url) }
|
||||
}
|
||||
|
||||
private fun openAdminSettings() {
|
||||
callbacks.forEach { it.openAdminSettings() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
@@ -151,6 +156,7 @@ class RoomDetailsNode @AssistedInject constructor(
|
||||
invitePeople = ::invitePeople,
|
||||
openAvatarPreview = ::openAvatarPreview,
|
||||
openPollHistory = ::openPollHistory,
|
||||
openAdminSettings = this::openAdminSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -71,6 +72,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
val leaveRoomState = leaveRoomPresenter.present()
|
||||
val canShowNotificationSettings = remember { mutableStateOf(false) }
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
val isUserAdmin = room.isOwnUserAdmin()
|
||||
|
||||
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
|
||||
|
||||
@@ -150,6 +152,7 @@ class RoomDetailsPresenter @Inject constructor(
|
||||
leaveRoomState = leaveRoomState,
|
||||
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
|
||||
isFavorite = isFavorite,
|
||||
displayAdminSettings = !room.isDm && isUserAdmin,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ data class RoomDetailsState(
|
||||
val leaveRoomState: LeaveRoomState,
|
||||
val roomNotificationSettings: RoomNotificationSettings?,
|
||||
val isFavorite: Boolean,
|
||||
val displayAdminSettings: Boolean,
|
||||
val eventSink: (RoomDetailsEvent) -> Unit
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
|
||||
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
|
||||
override val values: Sequence<RoomDetailsState>
|
||||
get() = sequenceOf(
|
||||
aRoomDetailsState(),
|
||||
aRoomDetailsState(displayAdminSettings = true),
|
||||
aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
|
||||
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
|
||||
aRoomDetailsState(isEncrypted = false),
|
||||
@@ -92,6 +92,7 @@ fun aRoomDetailsState(
|
||||
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
|
||||
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
|
||||
isFavorite: Boolean = false,
|
||||
displayAdminSettings: Boolean = false,
|
||||
eventSink: (RoomDetailsEvent) -> Unit = {},
|
||||
) = RoomDetailsState(
|
||||
roomId = roomId,
|
||||
@@ -109,6 +110,7 @@ fun aRoomDetailsState(
|
||||
leaveRoomState = leaveRoomState,
|
||||
roomNotificationSettings = roomNotificationSettings,
|
||||
isFavorite = isFavorite,
|
||||
displayAdminSettings = displayAdminSettings,
|
||||
eventSink = eventSink
|
||||
)
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ fun RoomDetailsView(
|
||||
invitePeople: () -> Unit,
|
||||
openAvatarPreview: (name: String, url: String) -> Unit,
|
||||
openPollHistory: () -> Unit,
|
||||
openAdminSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onShareMember() {
|
||||
@@ -160,30 +161,45 @@ fun RoomDetailsView(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
|
||||
NotificationSection(
|
||||
isDefaultMode = state.roomNotificationSettings.isDefault,
|
||||
openRoomNotificationSettings = openRoomNotificationSettings
|
||||
PreferenceCategory {
|
||||
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
|
||||
NotificationItem(
|
||||
isDefaultMode = state.roomNotificationSettings.isDefault,
|
||||
openRoomNotificationSettings = openRoomNotificationSettings
|
||||
)
|
||||
}
|
||||
|
||||
FavoriteItem(
|
||||
isFavorite = state.isFavorite,
|
||||
onFavoriteChanges = {
|
||||
state.eventSink(RoomDetailsEvent.SetFavorite(it))
|
||||
}
|
||||
)
|
||||
|
||||
if (state.displayAdminSettings) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Roles and permissions") },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
|
||||
onClick = openAdminSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FavoriteSection(
|
||||
isFavorite = state.isFavorite,
|
||||
onFavoriteChanges = {
|
||||
state.eventSink(RoomDetailsEvent.SetFavorite(it))
|
||||
}
|
||||
)
|
||||
|
||||
if (state.roomType is RoomDetailsType.Room) {
|
||||
MembersSection(
|
||||
memberCount = state.memberCount,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
)
|
||||
|
||||
if (state.canInvite) {
|
||||
InviteSection(
|
||||
invitePeople = invitePeople
|
||||
)
|
||||
val displayMemberListItem = state.roomType is RoomDetailsType.Room
|
||||
val displayInviteMembersItem = state.canInvite
|
||||
if (displayMemberListItem || displayInviteMembersItem) {
|
||||
PreferenceCategory {
|
||||
if (displayMemberListItem) {
|
||||
MembersItem(
|
||||
memberCount = state.memberCount,
|
||||
openRoomMemberList = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
if (displayInviteMembersItem) {
|
||||
InviteItem(
|
||||
invitePeople = invitePeople
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +365,7 @@ private fun TopicSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationSection(
|
||||
private fun NotificationItem(
|
||||
isDefaultMode: Boolean,
|
||||
openRoomNotificationSettings: () -> Unit,
|
||||
) {
|
||||
@@ -358,58 +374,49 @@ private fun NotificationSection(
|
||||
} else {
|
||||
stringResource(R.string.screen_room_details_notification_mode_custom)
|
||||
}
|
||||
PreferenceCategory {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
|
||||
supportingContent = { Text(text = subtitle) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
|
||||
onClick = openRoomNotificationSettings,
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
|
||||
supportingContent = { Text(text = subtitle) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
|
||||
onClick = openRoomNotificationSettings,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteSection(
|
||||
private fun FavoriteItem(
|
||||
isFavorite: Boolean,
|
||||
onFavoriteChanges: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PreferenceCategory(modifier = modifier) {
|
||||
PreferenceSwitch(
|
||||
icon = CompoundIcons.Favourite(),
|
||||
title = stringResource(id = CommonStrings.common_favourite),
|
||||
isChecked = isFavorite,
|
||||
onCheckedChange = onFavoriteChanges
|
||||
)
|
||||
}
|
||||
PreferenceSwitch(
|
||||
icon = CompoundIcons.Favourite(),
|
||||
title = stringResource(id = CommonStrings.common_favourite),
|
||||
isChecked = isFavorite,
|
||||
onCheckedChange = onFavoriteChanges
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MembersSection(
|
||||
private fun MembersItem(
|
||||
memberCount: Long,
|
||||
openRoomMemberList: () -> Unit,
|
||||
) {
|
||||
PreferenceCategory {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
|
||||
trailingContent = ListItemContent.Text(memberCount.toString()),
|
||||
onClick = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
|
||||
trailingContent = ListItemContent.Text(memberCount.toString()),
|
||||
onClick = openRoomMemberList,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InviteSection(
|
||||
private fun InviteItem(
|
||||
invitePeople: () -> Unit,
|
||||
) {
|
||||
PreferenceCategory {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
|
||||
onClick = invitePeople,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -481,5 +488,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
|
||||
invitePeople = {},
|
||||
openAvatarPreview = { _, _ -> },
|
||||
openPollHistory = {},
|
||||
openAdminSettings = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class RoomInviteMembersPresenter @Inject constructor(
|
||||
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
fetchMembers(roomMembers)
|
||||
@@ -99,7 +99,7 @@ class RoomInviteMembersPresenter @Inject constructor(
|
||||
@JvmName("toggleUserInSelectedUsers")
|
||||
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
|
||||
value = if (value.contains(user)) {
|
||||
value.filterNot { it == user }
|
||||
value.filterNot { it.userId == user.userId }
|
||||
} else {
|
||||
value + user
|
||||
}.toImmutableList()
|
||||
|
||||
@@ -48,7 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
|
||||
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
|
||||
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.model.getBestName
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -97,7 +97,7 @@ fun RoomInviteMembersView(
|
||||
)
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = state.selectedUsers,
|
||||
autoScroll = true,
|
||||
@@ -157,7 +157,7 @@ private fun RoomInviteMembersSearchBar(
|
||||
placeHolderTitle = placeHolderTitle,
|
||||
contentPrefix = {
|
||||
if (selectedUsers.isNotEmpty()) {
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
selectedUsers = selectedUsers,
|
||||
autoScroll = true,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
sealed interface RolesAndPermissionsEvents {
|
||||
data object ChangeOwnRole : RolesAndPermissionsEvents
|
||||
data class DemoteSelfTo(val role: RoomMember.Role) : RolesAndPermissionsEvents
|
||||
data object CancelPendingAction : RolesAndPermissionsEvents
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class RolesAndPermissionsFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.AdminSettings,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object AdminSettings : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object AdminList : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ModeratorList : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.AdminSettings -> {
|
||||
val callback = object : RolesAndPermissionsNode.Callback {
|
||||
override fun openAdminList() {
|
||||
backstack.push(NavTarget.AdminList)
|
||||
}
|
||||
|
||||
override fun openModeratorList() {
|
||||
backstack.push(NavTarget.ModeratorList)
|
||||
}
|
||||
}
|
||||
createNode<RolesAndPermissionsNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(callback),
|
||||
)
|
||||
}
|
||||
is NavTarget.AdminList -> {
|
||||
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins)
|
||||
createNode<ChangeRolesNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(inputs),
|
||||
)
|
||||
}
|
||||
is NavTarget.ModeratorList -> {
|
||||
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators)
|
||||
createNode<ChangeRolesNode>(
|
||||
buildContext = buildContext,
|
||||
plugins = listOf(inputs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.roomdetails.impl.rolesandpermissions
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class RolesAndPermissionsNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: RolesAndPermissionsPresenter,
|
||||
private val room: MatrixRoom,
|
||||
) : Node(buildContext, plugins = plugins), RoomDetailsAdminSettingsNavigator {
|
||||
interface Callback : Plugin {
|
||||
fun openAdminList()
|
||||
fun openModeratorList()
|
||||
}
|
||||
|
||||
private val callback = plugins<Callback>().first()
|
||||
|
||||
override fun onBackPressed() = navigateUp()
|
||||
override fun openAdminList() = callback.openAdminList()
|
||||
override fun openModeratorList() = callback.openModeratorList()
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
// Reload members when the user sees this screen
|
||||
lifecycle.addObserver(object : LifecycleEventObserver {
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
lifecycleScope.launch { room.updateMembers() }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// If the user is not an admin anymore, exit this section since they won't have permissions to use it
|
||||
lifecycleScope.launch {
|
||||
room.membersStateFlow
|
||||
.map { state ->
|
||||
state.roomMembers().orEmpty().find { it.userId == room.sessionId }
|
||||
}
|
||||
.filter { it?.role != RoomMember.Role.ADMIN }
|
||||
.take(1)
|
||||
.onEach { navigateUp() }
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
RolesAndPermissionsView(
|
||||
state = state,
|
||||
roomDetailsAdminSettingsNavigator = this,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface RoomDetailsAdminSettingsNavigator {
|
||||
fun onBackPressed() {}
|
||||
fun openAdminList() {}
|
||||
fun openModeratorList() {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class RolesAndPermissionsPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<RolesAndPermissionsState> {
|
||||
@Composable
|
||||
override fun present(): RolesAndPermissionsState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
val moderatorCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(RoomMember.Role.MODERATOR)
|
||||
}
|
||||
}
|
||||
val adminCount by remember {
|
||||
derivedStateOf {
|
||||
roomInfo.userCountWithRole(RoomMember.Role.ADMIN)
|
||||
}
|
||||
}
|
||||
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
|
||||
fun handleEvent(event: RolesAndPermissionsEvents) {
|
||||
when (event) {
|
||||
is RolesAndPermissionsEvents.ChangeOwnRole -> {
|
||||
changeOwnRoleAction.value = AsyncAction.Confirming
|
||||
}
|
||||
is RolesAndPermissionsEvents.CancelPendingAction -> {
|
||||
changeOwnRoleAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is RolesAndPermissionsEvents.DemoteSelfTo -> coroutineScope.demoteSelfTo(
|
||||
role = event.role,
|
||||
changeOwnRoleAction = changeOwnRoleAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return RolesAndPermissionsState(
|
||||
adminCount = adminCount,
|
||||
moderatorCount = moderatorCount,
|
||||
changeOwnRoleAction = changeOwnRoleAction.value,
|
||||
eventSink = { handleEvent(it) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.demoteSelfTo(
|
||||
role: RoomMember.Role,
|
||||
changeOwnRoleAction: MutableState<AsyncAction<Unit>>,
|
||||
) = launch(dispatchers.io) {
|
||||
runUpdatingState(changeOwnRoleAction) {
|
||||
room.updateUsersRoles(listOf(UserRoleChange(room.sessionId, role)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int {
|
||||
return if (this != null) {
|
||||
userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role }
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
data class RolesAndPermissionsState(
|
||||
val adminCount: Int,
|
||||
val moderatorCount: Int,
|
||||
val changeOwnRoleAction: AsyncAction<Unit>,
|
||||
val eventSink: (RolesAndPermissionsEvents) -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
|
||||
override val values: Sequence<RolesAndPermissionsState>
|
||||
get() = sequenceOf(
|
||||
aRolesAndPermissionsState(),
|
||||
aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2),
|
||||
aRolesAndPermissionsState(
|
||||
adminCount = 1,
|
||||
moderatorCount = 2,
|
||||
changeOwnRoleAction = AsyncAction.Confirming,
|
||||
),
|
||||
aRolesAndPermissionsState(
|
||||
adminCount = 1,
|
||||
moderatorCount = 2,
|
||||
changeOwnRoleAction = AsyncAction.Loading,
|
||||
),
|
||||
aRolesAndPermissionsState(
|
||||
adminCount = 1,
|
||||
moderatorCount = 2,
|
||||
changeOwnRoleAction = AsyncAction.Failure(IllegalStateException("Failed to change role")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRolesAndPermissionsState(
|
||||
adminCount: Int = 0,
|
||||
moderatorCount: Int = 0,
|
||||
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
|
||||
) = RolesAndPermissionsState(
|
||||
adminCount = adminCount,
|
||||
moderatorCount = moderatorCount,
|
||||
changeOwnRoleAction = changeOwnRoleAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions
|
||||
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun RolesAndPermissionsView(
|
||||
state: RolesAndPermissionsState,
|
||||
roomDetailsAdminSettingsNavigator: RoomDetailsAdminSettingsNavigator,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
PreferencePage(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.screen_room_roles_and_permissions_title),
|
||||
onBackPressed = roomDetailsAdminSettingsNavigator::onBackPressed,
|
||||
) {
|
||||
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_admins)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
|
||||
trailingContent = ListItemContent.Text("${state.adminCount}"),
|
||||
onClick = { roomDetailsAdminSettingsNavigator.openAdminList() },
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
|
||||
trailingContent = ListItemContent.Text("${state.moderatorCount}"),
|
||||
onClick = { roomDetailsAdminSettingsNavigator.openModeratorList() },
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
|
||||
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
when (state.changeOwnRoleAction) {
|
||||
is AsyncAction.Confirming -> {
|
||||
ChangeOwnRoleBottomSheet(
|
||||
eventSink = state.eventSink,
|
||||
)
|
||||
}
|
||||
is AsyncAction.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(CommonStrings.error_unknown),
|
||||
onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }
|
||||
)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChangeOwnRoleBottomSheet(
|
||||
eventSink: (RolesAndPermissionsEvents) -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val sheetState = if (LocalInspectionMode.current) {
|
||||
sheetStateForPreview()
|
||||
} else {
|
||||
rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
}
|
||||
fun dismiss() {
|
||||
sheetState.hide(coroutineScope) {
|
||||
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
||||
}
|
||||
}
|
||||
ModalBottomSheet(
|
||||
modifier = Modifier
|
||||
.systemBarsPadding()
|
||||
.navigationBarsPadding(),
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = ::dismiss,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
text = stringResource(R.string.screen_room_roles_and_permissions_change_my_role),
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 16.dp),
|
||||
text = stringResource(R.string.screen_room_change_role_confirm_demote_self_description),
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
|
||||
}
|
||||
},
|
||||
style = ListItemStyle.Destructive,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
|
||||
}
|
||||
},
|
||||
style = ListItemStyle.Destructive,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
||||
}
|
||||
},
|
||||
style = ListItemStyle.Primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomDetailsAdminSettingsViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) {
|
||||
ElementPreview {
|
||||
RolesAndPermissionsView(
|
||||
state = state,
|
||||
roomDetailsAdminSettingsNavigator = object : RoomDetailsAdminSettingsNavigator {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
sealed interface ChangeRolesEvent {
|
||||
data object ToggleSearchActive : ChangeRolesEvent
|
||||
data class QueryChanged(val query: String?) : ChangeRolesEvent
|
||||
data class UserSelectionToggled(val roomMember: RoomMember) : ChangeRolesEvent
|
||||
data object Save : ChangeRolesEvent
|
||||
data object Exit : ChangeRolesEvent
|
||||
data object CancelExit : ChangeRolesEvent
|
||||
data object ClearError : ChangeRolesEvent
|
||||
data object CancelSave : ChangeRolesEvent
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class ChangeRolesNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: ChangeRolesPresenter.Factory,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
sealed interface ListType : Parcelable {
|
||||
@Parcelize
|
||||
data object Admins : ListType
|
||||
@Parcelize
|
||||
data object Moderators : ListType
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Inputs(
|
||||
val listType: ListType,
|
||||
) : NodeInputs, Parcelable
|
||||
|
||||
private val inputs: Inputs = inputs()
|
||||
|
||||
private val presenter = presenterFactory.run {
|
||||
val role = when (inputs.listType) {
|
||||
is ListType.Admins -> RoomMember.Role.ADMIN
|
||||
is ListType.Moderators -> RoomMember.Role.MODERATOR
|
||||
}
|
||||
create(role)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ChangeRolesView(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onBackPressed = this::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
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.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ChangeRolesPresenter @AssistedInject constructor(
|
||||
@Assisted private val role: RoomMember.Role,
|
||||
private val room: MatrixRoom,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<ChangeRolesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(role: RoomMember.Role): ChangeRolesPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): ChangeRolesState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
|
||||
var query by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var searchResults by remember {
|
||||
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
|
||||
}
|
||||
val selectedUsers = remember {
|
||||
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
|
||||
}
|
||||
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val usersWithRole = produceState(initialValue = persistentListOf()) {
|
||||
room.usersWithRole(role)
|
||||
.map { members -> members.map { it.toMatrixUser() } }
|
||||
.onEach { users ->
|
||||
val previous: PersistentList<MatrixUser> = value
|
||||
value = users.toPersistentList()
|
||||
// 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 } }
|
||||
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
val roomMemberState by room.membersStateFlow.collectAsState()
|
||||
|
||||
// Update search results for every query change
|
||||
LaunchedEffect(query, roomMemberState) {
|
||||
val results = dataSource
|
||||
.search(query.orEmpty())
|
||||
.sorted()
|
||||
|
||||
searchResults = if (results.isEmpty()) {
|
||||
SearchBarResultState.NoResultsFound()
|
||||
} else {
|
||||
SearchBarResultState.Results(results)
|
||||
}
|
||||
}
|
||||
|
||||
val hasPendingChanges = usersWithRole.value != selectedUsers.value
|
||||
|
||||
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
|
||||
fun canChangeMemberRole(userId: UserId): Boolean {
|
||||
// An admin can't remove or demote another admin
|
||||
val powerLevel = roomInfo?.userPowerLevels?.get(userId) ?: 0L
|
||||
return RoomMember.Role.forPowerLevel(powerLevel) != RoomMember.Role.ADMIN
|
||||
}
|
||||
|
||||
fun handleEvent(event: ChangeRolesEvent) {
|
||||
when (event) {
|
||||
is ChangeRolesEvent.ToggleSearchActive -> {
|
||||
searchActive = !searchActive
|
||||
}
|
||||
is ChangeRolesEvent.QueryChanged -> {
|
||||
query = event.query
|
||||
}
|
||||
is ChangeRolesEvent.UserSelectionToggled -> {
|
||||
val newList = selectedUsers.value.toMutableList()
|
||||
val index = newList.indexOfFirst { it.userId == event.roomMember.userId }
|
||||
if (index >= 0) {
|
||||
newList.removeAt(index)
|
||||
} else {
|
||||
newList.add(event.roomMember.toMatrixUser())
|
||||
}
|
||||
selectedUsers.value = newList.toImmutableList()
|
||||
}
|
||||
is ChangeRolesEvent.Save -> {
|
||||
if (role == RoomMember.Role.ADMIN && selectedUsers != usersWithRole && !saveState.value.isConfirming()) {
|
||||
// Confirm adding admin
|
||||
saveState.value = AsyncAction.Confirming
|
||||
} else if (!saveState.value.isLoading()) {
|
||||
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
|
||||
}
|
||||
}
|
||||
is ChangeRolesEvent.ClearError -> {
|
||||
saveState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is ChangeRolesEvent.Exit -> {
|
||||
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) {
|
||||
// Has pending changes, confirm exit
|
||||
AsyncAction.Confirming
|
||||
} else {
|
||||
// No pending changes, exit immediately
|
||||
AsyncAction.Success(Unit)
|
||||
}
|
||||
}
|
||||
is ChangeRolesEvent.CancelExit -> {
|
||||
exitState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
is ChangeRolesEvent.CancelSave -> {
|
||||
saveState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
return ChangeRolesState(
|
||||
role = role,
|
||||
query = query,
|
||||
isSearchActive = searchActive,
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers.value,
|
||||
hasPendingChanges = hasPendingChanges,
|
||||
exitState = exitState.value,
|
||||
savingState = saveState.value,
|
||||
canChangeMemberRole = ::canChangeMemberRole,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
|
||||
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
|
||||
}
|
||||
|
||||
private fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
|
||||
private fun CoroutineScope.save(
|
||||
usersWithRole: ImmutableList<MatrixUser>,
|
||||
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
|
||||
saveState: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
saveState.value = AsyncAction.Loading
|
||||
|
||||
val toAdd = selectedUsers.value - usersWithRole
|
||||
val toRemove = usersWithRole - selectedUsers.value
|
||||
|
||||
val changes: List<UserRoleChange> = buildList {
|
||||
for (selectedUser in toAdd) {
|
||||
add(UserRoleChange(selectedUser.userId, role))
|
||||
}
|
||||
for (selectedUser in toRemove) {
|
||||
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
|
||||
}
|
||||
}
|
||||
|
||||
room.updateUsersRoles(changes)
|
||||
.onFailure {
|
||||
saveState.value = AsyncAction.Failure(it)
|
||||
}
|
||||
.onSuccess {
|
||||
saveState.value = AsyncAction.Success(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
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
|
||||
|
||||
data class ChangeRolesState(
|
||||
val role: RoomMember.Role,
|
||||
val query: String?,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
|
||||
val selectedUsers: ImmutableList<MatrixUser>,
|
||||
val hasPendingChanges: Boolean,
|
||||
val exitState: AsyncAction<Unit>,
|
||||
val savingState: AsyncAction<Unit>,
|
||||
val canChangeMemberRole: (UserId) -> Boolean,
|
||||
val eventSink: (ChangeRolesEvent) -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
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 io.element.android.libraries.matrix.ui.components.aMatrixUserList
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
|
||||
override val values: Sequence<ChangeRolesState>
|
||||
get() = sequenceOf(
|
||||
aChangeRolesState(),
|
||||
aChangeRolesState(role = RoomMember.Role.MODERATOR),
|
||||
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
|
||||
aChangeRolesStateWithSelectedUsers(),
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
|
||||
),
|
||||
aChangeRolesStateWithSelectedUsers().copy(
|
||||
query = "Alice",
|
||||
isSearchActive = true,
|
||||
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
|
||||
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
|
||||
),
|
||||
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Confirming),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
|
||||
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aChangeRolesState(
|
||||
role: RoomMember.Role = RoomMember.Role.ADMIN,
|
||||
query: String? = null,
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
|
||||
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
|
||||
hasPendingChanges: Boolean = false,
|
||||
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
canRemoveMember: (UserId) -> Boolean = { true },
|
||||
) = ChangeRolesState(
|
||||
role = role,
|
||||
query = query,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
selectedUsers = selectedUsers,
|
||||
hasPendingChanges = hasPendingChanges,
|
||||
exitState = exitState,
|
||||
savingState = savingState,
|
||||
canChangeMemberRole = canRemoveMember,
|
||||
eventSink = {},
|
||||
)
|
||||
|
||||
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
|
||||
selectedUsers = aMatrixUserList().toImmutableList(),
|
||||
searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
|
||||
hasPendingChanges = true,
|
||||
canRemoveMember = { it != UserId("@alice:server.org") },
|
||||
)
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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.impl.rolesandpermissions.changeroles
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
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.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.Checkbox
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.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.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,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
|
||||
BackHandler {
|
||||
if (state.isSearchActive) {
|
||||
state.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
} else {
|
||||
state.eventSink(ChangeRolesEvent.Exit)
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding(),
|
||||
topBar = {
|
||||
AnimatedVisibility(visible = !state.isSearchActive) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
val title = when (state.role) {
|
||||
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_change_role_administrators_title)
|
||||
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_role_moderators_title)
|
||||
RoomMember.Role.USER -> error("This should never be reached")
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_save),
|
||||
enabled = state.hasPendingChanges,
|
||||
onClick = { state.eventSink(ChangeRolesEvent.Save) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
|
||||
query = state.query.orEmpty(),
|
||||
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
|
||||
active = state.isSearchActive,
|
||||
onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) },
|
||||
resultState = state.searchResults,
|
||||
) { members ->
|
||||
SearchResultsList(
|
||||
isSearchActive = true,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = members,
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
|
||||
selectedUsersList = {},
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = !state.isSearchActive,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Column {
|
||||
SearchResultsList(
|
||||
isSearchActive = false,
|
||||
lazyListState = lazyListState,
|
||||
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
|
||||
selectedUsers = state.selectedUsers,
|
||||
canRemoveMember = state.canChangeMemberRole,
|
||||
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
|
||||
selectedUsersList = { users ->
|
||||
SelectedUsersRowList(
|
||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
selectedUsers = users,
|
||||
onUserRemoved = {
|
||||
state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId)))
|
||||
},
|
||||
canDeselect = { state.canChangeMemberRole(it.userId) },
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val asyncIndicatorState = rememberAsyncIndicatorState()
|
||||
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
|
||||
|
||||
when (state.exitState) {
|
||||
is AsyncAction.Confirming -> {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
|
||||
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
|
||||
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Exit) },
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
|
||||
)
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
SideEffect { updatedOnBackPressed() }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
when (state.savingState) {
|
||||
is AsyncAction.Confirming -> {
|
||||
if (state.role == RoomMember.Role.ADMIN) {
|
||||
// Confirm adding new admins dialogs
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
|
||||
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
|
||||
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Save) },
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
|
||||
)
|
||||
}
|
||||
}
|
||||
is AsyncAction.Loading -> {
|
||||
ProgressDialog()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
ErrorDialog(
|
||||
content = stringResource(CommonStrings.error_unknown),
|
||||
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
|
||||
)
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
LaunchedEffect(state.savingState) {
|
||||
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
|
||||
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SearchResultsList(
|
||||
isSearchActive: Boolean,
|
||||
searchResults: ImmutableList<RoomMember>,
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
canRemoveMember: (UserId) -> Boolean,
|
||||
onSelectionToggled: (RoomMember) -> Unit,
|
||||
lazyListState: LazyListState,
|
||||
selectedUsersList: @Composable (ImmutableList<MatrixUser>) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
) {
|
||||
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) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
MatrixUserRow(
|
||||
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
|
||||
matrixUser = MatrixUser(
|
||||
userId = roomMember.userId,
|
||||
displayName = roomMember.displayName,
|
||||
avatarUrl = roomMember.avatarUrl,
|
||||
),
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) {
|
||||
ElementPreview {
|
||||
ChangeRolesView(
|
||||
state = state,
|
||||
onBackPressed = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,29 @@
|
||||
<string name="screen_notification_settings_edit_failed_updating_default_mode">"An error occurred while updating the notification setting."</string>
|
||||
<string name="screen_notification_settings_mentions_only_disclaimer">"Your homeserver does not support this option in encrypted rooms, you may not get notified in some rooms."</string>
|
||||
<string name="screen_polls_history_title">"Polls"</string>
|
||||
<string name="screen_room_change_permissions_administrators">"Admins only"</string>
|
||||
<string name="screen_room_change_permissions_ban_people">"Ban people"</string>
|
||||
<string name="screen_room_change_permissions_delete_messages">"Delete messages"</string>
|
||||
<string name="screen_room_change_permissions_everyone">"Everyone"</string>
|
||||
<string name="screen_room_change_permissions_invite_people">"Invite people"</string>
|
||||
<string name="screen_room_change_permissions_member_moderation">"Member moderation"</string>
|
||||
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
|
||||
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
|
||||
<string name="screen_room_change_permissions_remove_people">"Remove people"</string>
|
||||
<string name="screen_room_change_permissions_room_avatar">"Change Room Avatar"</string>
|
||||
<string name="screen_room_change_permissions_room_details">"Room details"</string>
|
||||
<string name="screen_room_change_permissions_room_name">"Change Room Name"</string>
|
||||
<string name="screen_room_change_permissions_room_topic">"Change Room Topic"</string>
|
||||
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
|
||||
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
|
||||
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
|
||||
<string name="screen_room_change_role_confirm_add_admin_title">"Add Admin?"</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_action">"Demote"</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_description">"You will not be able to undo this change as you are demoting yourself, if you are the last privileged user in the room it will be impossible to regain privileges."</string>
|
||||
<string name="screen_room_change_role_confirm_demote_self_title">"Demote yourself?"</string>
|
||||
<string name="screen_room_change_role_moderators_title">"Edit Moderators"</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_description">"You have unsaved changes."</string>
|
||||
<string name="screen_room_change_role_unsaved_changes_title">"Save changes?"</string>
|
||||
<string name="screen_room_details_add_topic_title">"Add topic"</string>
|
||||
<string name="screen_room_details_already_a_member">"Already a member"</string>
|
||||
<string name="screen_room_details_already_invited">"Already invited"</string>
|
||||
@@ -70,5 +93,17 @@
|
||||
<string name="screen_room_notification_settings_mode_all_messages">"All messages"</string>
|
||||
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions and Keywords only"</string>
|
||||
<string name="screen_room_notification_settings_room_custom_settings_title">"In this room, notify me for"</string>
|
||||
<string name="screen_room_roles_and_permissions_admins">"Admins"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_my_role">"Change my role"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Demote to member"</string>
|
||||
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Demote to moderator"</string>
|
||||
<string name="screen_room_roles_and_permissions_member_moderation">"Member moderation"</string>
|
||||
<string name="screen_room_roles_and_permissions_messages_and_content">"Messages and content"</string>
|
||||
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
|
||||
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
|
||||
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
|
||||
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
|
||||
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
|
||||
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>
|
||||
<string name="screen_start_chat_error_starting_chat">"An error occurred when trying to start a chat"</string>
|
||||
</resources>
|
||||
|
||||
@@ -257,6 +257,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||
invitePeople: () -> Unit = EnsureNeverCalled(),
|
||||
openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
openPollHistory: () -> Unit = EnsureNeverCalled(),
|
||||
openAdminSettings: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsView(
|
||||
@@ -270,6 +271,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomD
|
||||
invitePeople = invitePeople,
|
||||
openAvatarPreview = openAvatarPreview,
|
||||
openPollHistory = openPollHistory,
|
||||
openAdminSettings = openAdminSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsEvents
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsPresenter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class RolesAndPermissionPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createRolesAndPermissionsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
with(awaitItem()) {
|
||||
assertThat(adminCount).isEqualTo(0)
|
||||
assertThat(moderatorCount).isEqualTo(0)
|
||||
assertThat(changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ChangeOwnRole presents a confirmation dialog`() = runTest {
|
||||
val presenter = createRolesAndPermissionsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole)
|
||||
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Confirming)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - DemoteSelfTo changes own role to the specified one`() = runTest(StandardTestDispatcher()) {
|
||||
val presenter = createRolesAndPermissionsPresenter(dispatchers = testCoroutineDispatchers())
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
|
||||
|
||||
runCurrent()
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)
|
||||
|
||||
runCurrent()
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - DemoteSelfTo can handle failures and clean them`() = runTest(StandardTestDispatcher()) {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenUpdateUserRoleResult(Result.failure(Exception("Failed to update role")))
|
||||
}
|
||||
val presenter = createRolesAndPermissionsPresenter(room = room, dispatchers = testCoroutineDispatchers())
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
|
||||
|
||||
runCurrent()
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Loading)
|
||||
|
||||
runCurrent()
|
||||
assertThat(awaitItem().changeOwnRoleAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
|
||||
initialState.eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelPendingAction dismisses confirmation dialog too`() = runTest {
|
||||
val presenter = createRolesAndPermissionsPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole)
|
||||
awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction)
|
||||
|
||||
assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createRolesAndPermissionsPresenter(
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
): RolesAndPermissionsPresenter {
|
||||
return RolesAndPermissionsPresenter(room = room, dispatchers = dispatchers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.roomdetails.impl.R
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsState
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsView
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RoomDetailsAdminSettingsNavigator
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.aRolesAndPermissionsState
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RolesAndPermissionsViewTests {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `click on back invokes expected callback`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRolesAndPermissionsView(
|
||||
goBack = callback,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping on Admins opens admin list`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRolesAndPermissionsView(
|
||||
openAdminList = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_roles_and_permissions_admins)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping on Moderators opens moderators list`() {
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRolesAndPermissionsView(
|
||||
openModeratorList = callback,
|
||||
)
|
||||
rule.clickOn(R.string.screen_room_roles_and_permissions_moderators)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRolesAndPermissionsView(
|
||||
state: RolesAndPermissionsState = aRolesAndPermissionsState(
|
||||
eventSink = EventsRecorder(expectEvents = false),
|
||||
),
|
||||
goBack: () -> Unit = EnsureNeverCalled(),
|
||||
openAdminList: () -> Unit = EnsureNeverCalled(),
|
||||
openModeratorList: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RolesAndPermissionsView(
|
||||
state = state,
|
||||
roomDetailsAdminSettingsNavigator = object : RoomDetailsAdminSettingsNavigator {
|
||||
override fun onBackPressed() = goBack()
|
||||
override fun openAdminList() = openAdminList()
|
||||
override fun openModeratorList() = openModeratorList()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* 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 app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMember
|
||||
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesEvent
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesPresenter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
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.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ChangeRolesPresenterTests {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createChangeRolesPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
with(awaitItem()) {
|
||||
assertThat(role).isEqualTo(RoomMember.Role.ADMIN)
|
||||
assertThat(query).isNull()
|
||||
assertThat(isSearchActive).isFalse()
|
||||
assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
assertThat(selectedUsers).isEmpty()
|
||||
assertThat(hasPendingChanges).isFalse()
|
||||
assertThat(exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
assertThat(savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial results are loaded automatically`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - ToggleSearchActive changes the value`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isTrue()
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.ToggleSearchActive)
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - QueryChanged produces new results`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(initialResults).hasSize(10)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - changes in the room members produce new results`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results.orEmpty()
|
||||
assertThat(initialResults).hasSize(10)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(2)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
assertThat(awaitItem().selectedUsers).hasSize(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(2)
|
||||
assertThat(hasPendingChanges).isTrue()
|
||||
}
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
with(awaitItem()) {
|
||||
assertThat(selectedUsers).hasSize(1)
|
||||
assertThat(hasPendingChanges).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Exit will display success if no pending changes`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelExit will remove exit confirmation`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Exit)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.exitState).isEqualTo(AsyncAction.Confirming)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.CancelExit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.hasPendingChanges).isFalse()
|
||||
assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
val updatedState = awaitItem()
|
||||
assertThat(updatedState.hasPendingChanges).isTrue()
|
||||
skipItems(1)
|
||||
|
||||
updatedState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Confirming)
|
||||
|
||||
updatedState.eventSink(ChangeRolesEvent.Exit)
|
||||
assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will display a confirmation when adding admins`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.Save)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelSave will remove the confirmation dialog`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 100)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.ADMIN, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val confirmingState = awaitItem()
|
||||
assertThat(confirmingState.savingState).isEqualTo(AsyncAction.Confirming)
|
||||
|
||||
confirmingState.eventSink(ChangeRolesEvent.CancelSave)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save will just save the data for moderators`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 50)))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Save can handle failures and ClearError clears them`() = runTest {
|
||||
val room = FakeMatrixRoom().apply {
|
||||
givenRoomMembersState(MatrixRoomMembersState.Ready(aRoomMemberList()))
|
||||
givenRoomInfo(aRoomInfo(userPowerLevels = persistentMapOf(A_USER_ID to 50)))
|
||||
givenUpdateUserRoleResult(Result.failure(IllegalStateException("Failed")))
|
||||
}
|
||||
val presenter = createChangeRolesPresenter(role = RoomMember.Role.MODERATOR, room = room)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.selectedUsers).hasSize(1)
|
||||
|
||||
initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(A_USER_ID_2)))
|
||||
|
||||
awaitItem().eventSink(ChangeRolesEvent.Save)
|
||||
val failedState = awaitItem()
|
||||
assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
|
||||
failedState.eventSink(ChangeRolesEvent.ClearError)
|
||||
assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createChangeRolesPresenter(
|
||||
role: RoomMember.Role = RoomMember.Role.ADMIN,
|
||||
room: FakeMatrixRoom = FakeMatrixRoom(),
|
||||
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
|
||||
): ChangeRolesPresenter {
|
||||
return ChangeRolesPresenter(
|
||||
role = role,
|
||||
room = room,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,19 @@
|
||||
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
|
||||
<string name="screen_roomlist_empty_title">"No chats yet."</string>
|
||||
<string name="screen_roomlist_filter_favourites">"Favourites"</string>
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_subtitle">"You can add a chat to your favourites in the chat settings.
|
||||
For now, you can deselect filters in order to see your other chats"</string>
|
||||
<string name="screen_roomlist_filter_favourites_empty_state_title">"You don’t have favourite chats yet"</string>
|
||||
<string name="screen_roomlist_filter_low_priority">"Low Priority"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"You can deselect filters in order to see your other chats"</string>
|
||||
<string name="screen_roomlist_filter_mixed_empty_state_title">"You don’t have chats for this selection"</string>
|
||||
<string name="screen_roomlist_filter_people">"People"</string>
|
||||
<string name="screen_roomlist_filter_people_empty_state_title">"You don’t have any DMs yet"</string>
|
||||
<string name="screen_roomlist_filter_rooms">"Rooms"</string>
|
||||
<string name="screen_roomlist_filter_rooms_empty_state_title">"You’re not in any room yet"</string>
|
||||
<string name="screen_roomlist_filter_unreads">"Unreads"</string>
|
||||
<string name="screen_roomlist_filter_unreads_empty_state_title">"Congrats!
|
||||
You don’t have any unread message!"</string>
|
||||
<string name="screen_roomlist_main_space_title">"Chats"</string>
|
||||
<string name="screen_roomlist_mark_as_read">"Mark as read"</string>
|
||||
<string name="screen_roomlist_mark_as_unread">"Mark as unread"</string>
|
||||
|
||||
@@ -29,12 +29,19 @@ import io.element.android.libraries.matrix.api.media.MediaUploadHandler
|
||||
import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
|
||||
@@ -56,7 +63,7 @@ interface MatrixRoom : Closeable {
|
||||
/** Whether the room is a direct message. */
|
||||
val isDm: Boolean get() = isDirect && isOneToOne
|
||||
|
||||
val roomInfoFlow: Flow<MatrixRoomInfo>
|
||||
val roomInfoFlow: SharedFlow<MatrixRoomInfo>
|
||||
val roomTypingMembersFlow: Flow<List<UserId>>
|
||||
|
||||
/**
|
||||
@@ -91,6 +98,10 @@ interface MatrixRoom : Closeable {
|
||||
|
||||
suspend fun unsubscribeFromSync()
|
||||
|
||||
suspend fun userRole(userId: UserId): Result<RoomMember.Role>
|
||||
|
||||
suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit>
|
||||
|
||||
suspend fun userDisplayName(userId: UserId): Result<String?>
|
||||
|
||||
suspend fun userAvatarUrl(userId: UserId): Result<String?>
|
||||
@@ -144,6 +155,18 @@ interface MatrixRoom : Closeable {
|
||||
suspend fun canUserJoinCall(userId: UserId): Result<Boolean> =
|
||||
canUserSendState(userId, StateEventType.CALL_MEMBER)
|
||||
|
||||
fun usersWithRole(role: RoomMember.Role): Flow<ImmutableList<RoomMember>> {
|
||||
return roomInfoFlow
|
||||
.map { it.userPowerLevels.filter { (_, powerLevel) -> RoomMember.Role.forPowerLevel(powerLevel) == role } }
|
||||
.distinctUntilChanged()
|
||||
.combine(membersStateFlow) { powerLevels, membersState ->
|
||||
membersState.roomMembers()
|
||||
.orEmpty()
|
||||
.filter { powerLevels.containsKey(it.userId) }
|
||||
.toPersistentList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||
|
||||
suspend fun removeAvatar(): Result<Unit>
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
@Immutable
|
||||
data class MatrixRoomInfo(
|
||||
@@ -39,6 +41,7 @@ data class MatrixRoomInfo(
|
||||
val activeMembersCount: Long,
|
||||
val invitedMembersCount: Long,
|
||||
val joinedMembersCount: Long,
|
||||
val userPowerLevels: ImmutableMap<UserId, Long>,
|
||||
val highlightCount: Long,
|
||||
val notificationCount: Long,
|
||||
val userDefinedNotificationMode: RoomNotificationMode?,
|
||||
|
||||
@@ -32,10 +32,20 @@ data class RoomMember(
|
||||
/**
|
||||
* Role of the RoomMember, based on its [powerLevel].
|
||||
*/
|
||||
enum class Role {
|
||||
ADMIN,
|
||||
MODERATOR,
|
||||
USER
|
||||
enum class Role(val powerLevel: Long) {
|
||||
ADMIN(100L),
|
||||
MODERATOR(50L),
|
||||
USER(0L);
|
||||
|
||||
companion object {
|
||||
fun forPowerLevel(powerLevel: Long): Role {
|
||||
return when {
|
||||
powerLevel >= ADMIN.powerLevel -> ADMIN
|
||||
powerLevel >= MODERATOR.powerLevel -> MODERATOR
|
||||
else -> USER
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.libraries.matrix.api.room.powerlevels
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
|
||||
data class UserRoleChange(
|
||||
val userId: UserId,
|
||||
val role: RoomMember.Role,
|
||||
) {
|
||||
val powerLevel: Long = role.powerLevel
|
||||
}
|
||||
@@ -16,12 +16,15 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import org.matrix.rustcomponents.sdk.Membership as RustMembership
|
||||
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
|
||||
@@ -49,6 +52,7 @@ class MatrixRoomInfoMapper(
|
||||
activeMembersCount = it.activeMembersCount.toLong(),
|
||||
invitedMembersCount = it.invitedMembersCount.toLong(),
|
||||
joinedMembersCount = it.joinedMembersCount.toLong(),
|
||||
userPowerLevels = mapPowerLevels(it.userPowerLevels),
|
||||
highlightCount = it.highlightCount.toLong(),
|
||||
notificationCount = it.notificationCount.toLong(),
|
||||
userDefinedNotificationMode = it.userDefinedNotificationMode?.map(),
|
||||
@@ -69,3 +73,7 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
|
||||
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
|
||||
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
|
||||
}
|
||||
|
||||
fun mapPowerLevels(powerLevels: Map<String, Long>): ImmutableMap<UserId, Long> {
|
||||
return powerLevels.mapKeys { (key, _) -> UserId(key) }.toPersistentMap()
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoomNotificationSettingsState
|
||||
import io.element.android.libraries.matrix.api.room.Mention
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
@@ -51,6 +53,7 @@ import io.element.android.libraries.matrix.impl.notificationsettings.RustNotific
|
||||
import io.element.android.libraries.matrix.impl.poll.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.location.toInner
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
@@ -63,8 +66,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
@@ -74,6 +80,7 @@ import org.matrix.rustcomponents.sdk.RoomListItem
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
|
||||
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
|
||||
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilities
|
||||
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
@@ -102,7 +109,7 @@ class RustMatrixRoom(
|
||||
) : MatrixRoom {
|
||||
override val roomId = RoomId(innerRoom.id())
|
||||
|
||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = mxCallbackFlow {
|
||||
override val roomInfoFlow: SharedFlow<MatrixRoomInfo> = mxCallbackFlow {
|
||||
launch {
|
||||
val initial = innerRoom.roomInfo().use(matrixRoomInfoMapper::map)
|
||||
channel.trySend(initial)
|
||||
@@ -113,6 +120,7 @@ class RustMatrixRoom(
|
||||
}
|
||||
})
|
||||
}
|
||||
.shareIn(sessionCoroutineScope, SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
|
||||
launch {
|
||||
@@ -228,6 +236,19 @@ class RustMatrixRoom(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> = withContext(coroutineDispatchers.io) {
|
||||
runCatching {
|
||||
RoomMemberMapper.mapRole(innerRoom.suggestedRoleForUser(userId.value))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
|
||||
return runCatching {
|
||||
val powerLevelChanges = changes.map { UserPowerLevelUpdate(it.userId.value, it.powerLevel) }
|
||||
innerRoom.updatePowerLevelsForUsers(powerLevelChanges)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) {
|
||||
runCatching {
|
||||
innerRoom.memberAvatarUrl(userId.value)
|
||||
|
||||
@@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
@@ -54,11 +55,14 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
|
||||
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.io.File
|
||||
|
||||
@@ -86,6 +90,7 @@ class FakeMatrixRoom(
|
||||
private var unignoreResult: Result<Unit> = Result.success(Unit)
|
||||
private var userDisplayNameResult = Result.success<String?>(null)
|
||||
private var userAvatarUrlResult = Result.success<String?>(null)
|
||||
private var userRoleResult = Result.success(RoomMember.Role.USER)
|
||||
private var updateMembersResult: Result<Unit> = Result.success(Unit)
|
||||
private var joinRoomResult = Result.success(Unit)
|
||||
private var inviteUserResult = Result.success(Unit)
|
||||
@@ -100,6 +105,7 @@ class FakeMatrixRoom(
|
||||
private var setTopicResult = Result.success(Unit)
|
||||
private var updateAvatarResult = Result.success(Unit)
|
||||
private var removeAvatarResult = Result.success(Unit)
|
||||
private var updateUserRoleResult = Result.success(Unit)
|
||||
private var toggleReactionResult = Result.success(Unit)
|
||||
private var retrySendMessageResult = Result.success(Unit)
|
||||
private var cancelSendResult = Result.success(Unit)
|
||||
@@ -170,7 +176,7 @@ class FakeMatrixRoom(
|
||||
private var leaveRoomError: Throwable? = null
|
||||
|
||||
private val _roomInfoFlow: MutableSharedFlow<MatrixRoomInfo> = MutableSharedFlow(replay = 1)
|
||||
override val roomInfoFlow: Flow<MatrixRoomInfo> = _roomInfoFlow
|
||||
override val roomInfoFlow: SharedFlow<MatrixRoomInfo> = _roomInfoFlow
|
||||
|
||||
private val _roomTypingMembersFlow: MutableSharedFlow<List<UserId>> = MutableSharedFlow(replay = 1)
|
||||
override val roomTypingMembersFlow: Flow<List<UserId>> = _roomTypingMembersFlow
|
||||
@@ -206,6 +212,14 @@ class FakeMatrixRoom(
|
||||
userAvatarUrlResult
|
||||
}
|
||||
|
||||
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> {
|
||||
return userRoleResult
|
||||
}
|
||||
|
||||
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
|
||||
return updateUserRoleResult
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(body: String, htmlBody: String?, mentions: List<Mention>) = simulateLongTask {
|
||||
sendMessageMentions = mentions
|
||||
Result.success(Unit)
|
||||
@@ -496,6 +510,14 @@ class FakeMatrixRoom(
|
||||
userAvatarUrlResult = avatarUrl
|
||||
}
|
||||
|
||||
fun givenUserRoleResult(role: Result<RoomMember.Role>) {
|
||||
userRoleResult = role
|
||||
}
|
||||
|
||||
fun givenUpdateUserRoleResult(result: Result<Unit>) {
|
||||
updateUserRoleResult = result
|
||||
}
|
||||
|
||||
fun givenJoinRoomResult(result: Result<Unit>) {
|
||||
joinRoomResult = result
|
||||
}
|
||||
@@ -668,6 +690,7 @@ fun aRoomInfo(
|
||||
notificationCount: Long = 0,
|
||||
userDefinedNotificationMode: RoomNotificationMode? = null,
|
||||
hasRoomCall: Boolean = false,
|
||||
userPowerLevels: ImmutableMap<UserId, Long> = persistentMapOf(),
|
||||
activeRoomCallParticipants: List<String> = emptyList()
|
||||
) = MatrixRoomInfo(
|
||||
id = id,
|
||||
@@ -691,5 +714,6 @@ fun aRoomInfo(
|
||||
notificationCount = notificationCount,
|
||||
userDefinedNotificationMode = userDefinedNotificationMode,
|
||||
hasRoomCall = hasRoomCall,
|
||||
userPowerLevels = userPowerLevels,
|
||||
activeRoomCallParticipants = activeRoomCallParticipants.toImmutableList(),
|
||||
)
|
||||
|
||||
@@ -51,6 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun SelectedUser(
|
||||
matrixUser: MatrixUser,
|
||||
canRemove: Boolean,
|
||||
onUserRemoved: (MatrixUser) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -70,24 +71,26 @@ fun SelectedUser(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.clickable(
|
||||
indication = rememberRipple(),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = { onUserRemoved(matrixUser) }
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(id = CommonStrings.action_remove),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
)
|
||||
if (canRemove) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.clickable(
|
||||
indication = rememberRipple(),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = { onUserRemoved(matrixUser) }
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(id = CommonStrings.action_remove),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +100,17 @@ fun SelectedUser(
|
||||
internal fun SelectedUserPreview() = ElementPreview {
|
||||
SelectedUser(
|
||||
aMatrixUser(),
|
||||
canRemove = true,
|
||||
onUserRemoved = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SelectedUserCannotRemovePreview() = ElementPreview {
|
||||
SelectedUser(
|
||||
aMatrixUser(),
|
||||
canRemove = false,
|
||||
onUserRemoved = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,11 +46,12 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlin.math.floor
|
||||
|
||||
@Composable
|
||||
fun SelectedUsersList(
|
||||
fun SelectedUsersRowList(
|
||||
selectedUsers: ImmutableList<MatrixUser>,
|
||||
onUserRemoved: (MatrixUser) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
autoScroll: Boolean = false,
|
||||
canDeselect: (MatrixUser) -> Boolean = { true },
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
@@ -105,11 +106,12 @@ fun SelectedUsersList(
|
||||
.fillMaxWidth(),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
itemsIndexed(selectedUsers.toList()) { index, matrixUser ->
|
||||
itemsIndexed(selectedUsers.toList()) { index, selectedUser ->
|
||||
Layout(
|
||||
content = {
|
||||
SelectedUser(
|
||||
matrixUser = matrixUser,
|
||||
matrixUser = selectedUser,
|
||||
canRemove = canDeselect(selectedUser),
|
||||
onUserRemoved = onUserRemoved,
|
||||
)
|
||||
},
|
||||
@@ -133,7 +135,7 @@ fun SelectedUsersList(
|
||||
internal fun SelectedUsersListPreview() = ElementPreview {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
// Two users that will be visible with no scrolling
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
|
||||
onUserRemoved = {},
|
||||
modifier = Modifier
|
||||
@@ -143,7 +145,7 @@ internal fun SelectedUsersListPreview() = ElementPreview {
|
||||
|
||||
// Multiple users that don't fit, so will be spaced out per the measure policy
|
||||
for (i in 0..5) {
|
||||
SelectedUsersList(
|
||||
SelectedUsersRowList(
|
||||
selectedUsers = aMatrixUserList().take(6).toImmutableList(),
|
||||
onUserRemoved = {},
|
||||
modifier = Modifier
|
||||
@@ -18,9 +18,12 @@ package io.element.android.libraries.matrix.ui.room
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage
|
||||
@@ -45,3 +48,10 @@ fun MatrixRoom.canRedactOtherAsState(updateKey: Long): State<Boolean> {
|
||||
value = canRedactOther().getOrElse { false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MatrixRoom.isOwnUserAdmin(): Boolean {
|
||||
val roomInfo by roomInfoFlow.collectAsState(initial = null)
|
||||
val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L
|
||||
return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<string name="action_enter_pin">"Enter PIN"</string>
|
||||
<string name="action_forgot_password">"Forgot password?"</string>
|
||||
<string name="action_forward">"Forward"</string>
|
||||
<string name="action_go_back">"Go back"</string>
|
||||
<string name="action_invite">"Invite"</string>
|
||||
<string name="action_invite_friends">"Invite people"</string>
|
||||
<string name="action_invite_friends_to_app">"Invite people to %1$s"</string>
|
||||
@@ -181,6 +182,7 @@
|
||||
<string name="common_room">"Room"</string>
|
||||
<string name="common_room_name">"Room name"</string>
|
||||
<string name="common_room_name_placeholder">"e.g. your project name"</string>
|
||||
<string name="common_saved_changes">"Saved changes"</string>
|
||||
<string name="common_saving">"Saving"</string>
|
||||
<string name="common_screen_lock">"Screen lock"</string>
|
||||
<string name="common_search_for_someone">"Search for someone"</string>
|
||||
@@ -224,6 +226,8 @@
|
||||
<string name="dialog_title_error">"Error"</string>
|
||||
<string name="dialog_title_success">"Success"</string>
|
||||
<string name="dialog_title_warning">"Warning"</string>
|
||||
<string name="dialog_unsaved_changes_description_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
|
||||
<string name="dialog_unsaved_changes_title">"Save changes?"</string>
|
||||
<string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string>
|
||||
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
|
||||
<string name="error_failed_loading_messages">"Failed loading messages"</string>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user