Merge pull request #5950 from element-hq/feature/fga/iterate_permissions_screen
Changes : iterate again on permissions
This commit is contained in:
commit
76bc487f28
36 changed files with 243 additions and 151 deletions
|
|
@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.permissions
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -20,9 +21,11 @@ import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
|
import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
|
import io.element.android.libraries.matrix.ui.model.powerLevelOf
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import kotlinx.collections.immutable.toImmutableMap
|
||||||
|
|
@ -52,7 +55,6 @@ class ChangeRoomPermissionsPresenter(
|
||||||
)
|
)
|
||||||
RoomPermissionsSection.ManageSpace -> persistentListOf(
|
RoomPermissionsSection.ManageSpace -> persistentListOf(
|
||||||
RoomPermissionType.SPACE_MANAGE_ROOMS,
|
RoomPermissionType.SPACE_MANAGE_ROOMS,
|
||||||
RoomPermissionType.CHANGE_SETTINGS,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +92,10 @@ class ChangeRoomPermissionsPresenter(
|
||||||
derivedStateOf { initialPermissions != currentPermissions }
|
derivedStateOf { initialPermissions != currentPermissions }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val ownPowerLevel by remember {
|
||||||
|
room.roomInfoFlow.mapState { it.powerLevelOf(room.sessionId) }
|
||||||
|
}.collectAsState()
|
||||||
|
|
||||||
fun handleEvent(event: ChangeRoomPermissionsEvent) {
|
fun handleEvent(event: ChangeRoomPermissionsEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
|
is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> {
|
||||||
|
|
@ -108,7 +114,6 @@ class ChangeRoomPermissionsPresenter(
|
||||||
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
|
RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel)
|
||||||
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
|
RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel)
|
||||||
RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel)
|
RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel)
|
||||||
RoomPermissionType.CHANGE_SETTINGS -> currentPermissions?.copy(stateDefault = powerLevel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
|
is ChangeRoomPermissionsEvent.Save -> coroutineScope.save()
|
||||||
|
|
@ -125,6 +130,7 @@ class ChangeRoomPermissionsPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ChangeRoomPermissionsState(
|
return ChangeRoomPermissionsState(
|
||||||
|
ownPowerLevel = ownPowerLevel,
|
||||||
currentPermissions = currentPermissions,
|
currentPermissions = currentPermissions,
|
||||||
itemsBySection = itemsBySection,
|
itemsBySection = itemsBySection,
|
||||||
hasChanges = hasChanges,
|
hasChanges = hasChanges,
|
||||||
|
|
|
||||||
|
|
@ -18,35 +18,55 @@ import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.ImmutableMap
|
import kotlinx.collections.immutable.ImmutableMap
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
data class ChangeRoomPermissionsState(
|
data class ChangeRoomPermissionsState(
|
||||||
|
private val ownPowerLevel: Long,
|
||||||
val currentPermissions: RoomPowerLevelsValues?,
|
val currentPermissions: RoomPowerLevelsValues?,
|
||||||
val itemsBySection: ImmutableMap<RoomPermissionsSection, ImmutableList<RoomPermissionType>>,
|
val itemsBySection: ImmutableMap<RoomPermissionsSection, ImmutableList<RoomPermissionType>>,
|
||||||
val hasChanges: Boolean,
|
val hasChanges: Boolean,
|
||||||
val saveAction: AsyncAction<Boolean>,
|
val saveAction: AsyncAction<Boolean>,
|
||||||
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
|
val eventSink: (ChangeRoomPermissionsEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
private val ownRole = RoomMember.Role.forPowerLevel(ownPowerLevel)
|
||||||
|
|
||||||
|
// Roles that the user can select based on their own role
|
||||||
|
val selectableRoles: ImmutableList<SelectableRole> = when (ownRole) {
|
||||||
|
is RoomMember.Role.Owner,
|
||||||
|
RoomMember.Role.Admin -> persistentListOf(SelectableRole.Admin, SelectableRole.Moderator, SelectableRole.Everyone)
|
||||||
|
RoomMember.Role.Moderator -> persistentListOf(SelectableRole.Moderator, SelectableRole.Everyone)
|
||||||
|
RoomMember.Role.User -> persistentListOf(SelectableRole.Everyone)
|
||||||
|
}
|
||||||
|
|
||||||
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
|
fun selectedRoleForType(type: RoomPermissionType): SelectableRole? {
|
||||||
if (currentPermissions == null) return null
|
val powerLevel = currentPowerLevelForType(type = type) ?: return null
|
||||||
val role = when (type) {
|
return when (RoomMember.Role.forPowerLevel(powerLevel)) {
|
||||||
RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban)
|
|
||||||
RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite)
|
|
||||||
RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick)
|
|
||||||
RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.eventsDefault)
|
|
||||||
RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents)
|
|
||||||
RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName)
|
|
||||||
RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar)
|
|
||||||
RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic)
|
|
||||||
RoomPermissionType.SPACE_MANAGE_ROOMS -> RoomMember.Role.forPowerLevel(currentPermissions.spaceChild)
|
|
||||||
RoomPermissionType.CHANGE_SETTINGS -> RoomMember.Role.forPowerLevel(currentPermissions.stateDefault)
|
|
||||||
}
|
|
||||||
return when (role) {
|
|
||||||
is RoomMember.Role.Owner,
|
is RoomMember.Role.Owner,
|
||||||
RoomMember.Role.Admin -> SelectableRole.Admin
|
RoomMember.Role.Admin -> SelectableRole.Admin
|
||||||
RoomMember.Role.Moderator -> SelectableRole.Moderator
|
RoomMember.Role.Moderator -> SelectableRole.Moderator
|
||||||
RoomMember.Role.User -> SelectableRole.Everyone
|
RoomMember.Role.User -> SelectableRole.Everyone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun canChangePermission(type: RoomPermissionType): Boolean {
|
||||||
|
val currentPowerLevel = currentPowerLevelForType(type) ?: return false
|
||||||
|
return ownPowerLevel >= currentPowerLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentPowerLevelForType(type: RoomPermissionType): Long? {
|
||||||
|
if (currentPermissions == null) return null
|
||||||
|
return when (type) {
|
||||||
|
RoomPermissionType.BAN -> currentPermissions.ban
|
||||||
|
RoomPermissionType.INVITE -> currentPermissions.invite
|
||||||
|
RoomPermissionType.KICK -> currentPermissions.kick
|
||||||
|
RoomPermissionType.SEND_EVENTS -> currentPermissions.eventsDefault
|
||||||
|
RoomPermissionType.REDACT_EVENTS -> currentPermissions.redactEvents
|
||||||
|
RoomPermissionType.ROOM_NAME -> currentPermissions.roomName
|
||||||
|
RoomPermissionType.ROOM_AVATAR -> currentPermissions.roomAvatar
|
||||||
|
RoomPermissionType.ROOM_TOPIC -> currentPermissions.roomTopic
|
||||||
|
RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions.spaceChild
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class RoomPermissionsSection {
|
enum class RoomPermissionsSection {
|
||||||
|
|
@ -84,5 +104,4 @@ enum class RoomPermissionType {
|
||||||
ROOM_AVATAR,
|
ROOM_AVATAR,
|
||||||
ROOM_TOPIC,
|
ROOM_TOPIC,
|
||||||
SPACE_MANAGE_ROOMS,
|
SPACE_MANAGE_ROOMS,
|
||||||
CHANGE_SETTINGS,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPe
|
||||||
override val values: Sequence<ChangeRoomPermissionsState>
|
override val values: Sequence<ChangeRoomPermissionsState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aChangeRoomPermissionsState(),
|
aChangeRoomPermissionsState(),
|
||||||
|
aChangeRoomPermissionsState(ownPowerLevel = RoomMember.Role.Moderator.powerLevel),
|
||||||
aChangeRoomPermissionsState(hasChanges = true),
|
aChangeRoomPermissionsState(hasChanges = true),
|
||||||
aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
|
aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading),
|
||||||
aChangeRoomPermissionsState(
|
aChangeRoomPermissionsState(
|
||||||
|
|
@ -31,12 +32,14 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider<ChangeRoomPe
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun aChangeRoomPermissionsState(
|
internal fun aChangeRoomPermissionsState(
|
||||||
|
ownPowerLevel: Long = RoomMember.Role.Admin.powerLevel,
|
||||||
currentPermissions: RoomPowerLevelsValues = previewPermissions(),
|
currentPermissions: RoomPowerLevelsValues = previewPermissions(),
|
||||||
itemsBySection: Map<RoomPermissionsSection, ImmutableList<RoomPermissionType>> = ChangeRoomPermissionsPresenter.buildItems(false),
|
itemsBySection: Map<RoomPermissionsSection, ImmutableList<RoomPermissionType>> = ChangeRoomPermissionsPresenter.buildItems(false),
|
||||||
hasChanges: Boolean = false,
|
hasChanges: Boolean = false,
|
||||||
saveAction: AsyncAction<Boolean> = AsyncAction.Uninitialized,
|
saveAction: AsyncAction<Boolean> = AsyncAction.Uninitialized,
|
||||||
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
|
eventSink: (ChangeRoomPermissionsEvent) -> Unit = {},
|
||||||
) = ChangeRoomPermissionsState(
|
) = ChangeRoomPermissionsState(
|
||||||
|
ownPowerLevel = ownPowerLevel,
|
||||||
currentPermissions = currentPermissions,
|
currentPermissions = currentPermissions,
|
||||||
itemsBySection = itemsBySection.toImmutableMap(),
|
itemsBySection = itemsBySection.toImmutableMap(),
|
||||||
hasChanges = hasChanges,
|
hasChanges = hasChanges,
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -74,7 +73,8 @@ fun ChangeRoomPermissionsView(
|
||||||
PreferenceDropdown(
|
PreferenceDropdown(
|
||||||
title = titleForType(permissionType),
|
title = titleForType(permissionType),
|
||||||
selectedOption = state.selectedRoleForType(permissionType),
|
selectedOption = state.selectedRoleForType(permissionType),
|
||||||
options = SelectableRole.entries.toImmutableList(),
|
options = state.selectableRoles,
|
||||||
|
enabled = state.canChangePermission(permissionType),
|
||||||
onSelectOption = { role ->
|
onSelectOption = { role ->
|
||||||
state.eventSink(
|
state.eventSink(
|
||||||
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
|
ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(
|
||||||
|
|
@ -127,7 +127,6 @@ private fun titleForType(type: RoomPermissionType): String = when (type) {
|
||||||
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
|
RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar)
|
||||||
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
|
RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic)
|
||||||
RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms)
|
RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms)
|
||||||
RoomPermissionType.CHANGE_SETTINGS -> stringResource(R.string.screen_room_change_permissions_change_settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole
|
||||||
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
import io.element.android.libraries.matrix.api.room.toMatrixUser
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.ui.model.powerLevelOf
|
||||||
import io.element.android.libraries.matrix.ui.model.roleOf
|
import io.element.android.libraries.matrix.ui.model.roleOf
|
||||||
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -124,9 +125,10 @@ class ChangeRolesPresenter(
|
||||||
|
|
||||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||||
fun canChangeMemberRole(userId: UserId): Boolean {
|
fun canChangeMemberRole(userId: UserId): Boolean {
|
||||||
val currentUserRole = roomInfo.roleOf(room.sessionId)
|
val currentUserPowerLevel = roomInfo.powerLevelOf(room.sessionId)
|
||||||
val otherUserRole = roomInfo.roleOf(userId)
|
val otherUserPowerLevel = roomInfo.powerLevelOf(userId)
|
||||||
return currentUserRole.powerLevel > otherUserRole.powerLevel
|
return currentUserPowerLevel > otherUserPowerLevel &&
|
||||||
|
currentUserPowerLevel >= role.powerLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: ChangeRolesEvent) {
|
fun handleEvent(event: ChangeRolesEvent) {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole
|
import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole
|
||||||
import io.element.android.libraries.matrix.ui.model.roleOf
|
import io.element.android.libraries.matrix.ui.model.roleOf
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
@ -49,7 +50,16 @@ class RolesAndPermissionsPresenter(
|
||||||
room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner }
|
room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner }
|
||||||
}.collectAsState(null)
|
}.collectAsState(null)
|
||||||
|
|
||||||
val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } }
|
val availableDemoteActions by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val currentRole = roomInfo.roleOf(room.sessionId)
|
||||||
|
when (currentRole) {
|
||||||
|
is RoomMember.Role.Admin -> persistentListOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember)
|
||||||
|
is RoomMember.Role.Moderator -> persistentListOf(SelfDemoteAction.ToMember)
|
||||||
|
else -> persistentListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
val resetPermissionsAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
val resetPermissionsAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||||
|
|
||||||
|
|
@ -78,7 +88,7 @@ class RolesAndPermissionsPresenter(
|
||||||
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
|
roomSupportsOwnerRole = roomInfo.privilegedCreatorRole,
|
||||||
adminCount = adminCount,
|
adminCount = adminCount,
|
||||||
moderatorCount = moderatorCount,
|
moderatorCount = moderatorCount,
|
||||||
canDemoteSelf = canDemoteSelf.value,
|
availableSelfDemoteActions = availableDemoteActions,
|
||||||
changeOwnRoleAction = changeOwnRoleAction.value,
|
changeOwnRoleAction = changeOwnRoleAction.value,
|
||||||
resetPermissionsAction = resetPermissionsAction.value,
|
resetPermissionsAction = resetPermissionsAction.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,24 @@
|
||||||
|
|
||||||
package io.element.android.features.rolesandpermissions.impl.root
|
package io.element.android.features.rolesandpermissions.impl.root
|
||||||
|
|
||||||
|
import io.element.android.features.rolesandpermissions.impl.R
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
data class RolesAndPermissionsState(
|
data class RolesAndPermissionsState(
|
||||||
val roomSupportsOwnerRole: Boolean,
|
val roomSupportsOwnerRole: Boolean,
|
||||||
val adminCount: Int?,
|
val adminCount: Int?,
|
||||||
val moderatorCount: Int?,
|
val moderatorCount: Int?,
|
||||||
val canDemoteSelf: Boolean,
|
val availableSelfDemoteActions: ImmutableList<SelfDemoteAction>,
|
||||||
val changeOwnRoleAction: AsyncAction<Unit>,
|
val changeOwnRoleAction: AsyncAction<Unit>,
|
||||||
val resetPermissionsAction: AsyncAction<Unit>,
|
val resetPermissionsAction: AsyncAction<Unit>,
|
||||||
val eventSink: (RolesAndPermissionsEvents) -> Unit,
|
val eventSink: (RolesAndPermissionsEvents) -> Unit,
|
||||||
)
|
) {
|
||||||
|
val canSelfDemote = availableSelfDemoteActions.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SelfDemoteAction(val role: RoomMember.Role, val titleRes: Int) {
|
||||||
|
ToModerator(RoomMember.Role.Moderator, R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator),
|
||||||
|
ToMember(RoomMember.Role.User, R.string.screen_room_roles_and_permissions_change_role_demote_to_member)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.root
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
|
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
|
||||||
override val values: Sequence<RolesAndPermissionsState>
|
override val values: Sequence<RolesAndPermissionsState>
|
||||||
|
|
@ -46,7 +47,7 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermis
|
||||||
moderatorCount = 2,
|
moderatorCount = 2,
|
||||||
resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")),
|
resetPermissionsAction = AsyncAction.Failure(IllegalStateException("Failed to reset permissions")),
|
||||||
),
|
),
|
||||||
aRolesAndPermissionsState(canDemoteSelf = false),
|
aRolesAndPermissionsState(availableSelfDemoteActions = emptyList()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,14 +55,14 @@ internal fun aRolesAndPermissionsState(
|
||||||
roomSupportsOwners: Boolean = true,
|
roomSupportsOwners: Boolean = true,
|
||||||
adminCount: Int = 0,
|
adminCount: Int = 0,
|
||||||
moderatorCount: Int = 0,
|
moderatorCount: Int = 0,
|
||||||
canDemoteSelf: Boolean = true,
|
availableSelfDemoteActions: List<SelfDemoteAction> = listOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember),
|
||||||
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
resetPermissionsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
resetPermissionsAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
|
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
|
||||||
) = RolesAndPermissionsState(
|
) = RolesAndPermissionsState(
|
||||||
roomSupportsOwnerRole = roomSupportsOwners,
|
roomSupportsOwnerRole = roomSupportsOwners,
|
||||||
adminCount = adminCount,
|
adminCount = adminCount,
|
||||||
canDemoteSelf = canDemoteSelf,
|
availableSelfDemoteActions = availableSelfDemoteActions.toImmutableList(),
|
||||||
moderatorCount = moderatorCount,
|
moderatorCount = moderatorCount,
|
||||||
changeOwnRoleAction = changeOwnRoleAction,
|
changeOwnRoleAction = changeOwnRoleAction,
|
||||||
resetPermissionsAction = resetPermissionsAction,
|
resetPermissionsAction = resetPermissionsAction,
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea
|
||||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
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.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.hide
|
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
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RolesAndPermissionsView(
|
fun RolesAndPermissionsView(
|
||||||
|
|
@ -76,7 +76,7 @@ fun RolesAndPermissionsView(
|
||||||
},
|
},
|
||||||
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
|
onClick = { rolesAndPermissionsNavigator.openModeratorList() },
|
||||||
)
|
)
|
||||||
if (state.canDemoteSelf) {
|
if (state.canSelfDemote) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
|
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
|
||||||
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
|
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
|
||||||
|
|
@ -117,6 +117,7 @@ fun RolesAndPermissionsView(
|
||||||
when (state.changeOwnRoleAction) {
|
when (state.changeOwnRoleAction) {
|
||||||
is AsyncAction.Confirming -> {
|
is AsyncAction.Confirming -> {
|
||||||
ChangeOwnRoleBottomSheet(
|
ChangeOwnRoleBottomSheet(
|
||||||
|
availableDemoteActions = state.availableSelfDemoteActions,
|
||||||
eventSink = state.eventSink,
|
eventSink = state.eventSink,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +137,7 @@ fun RolesAndPermissionsView(
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChangeOwnRoleBottomSheet(
|
private fun ChangeOwnRoleBottomSheet(
|
||||||
|
availableDemoteActions: ImmutableList<SelfDemoteAction>,
|
||||||
eventSink: (RolesAndPermissionsEvents) -> Unit,
|
eventSink: (RolesAndPermissionsEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
@ -164,24 +166,17 @@ private fun ChangeOwnRoleBottomSheet(
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
)
|
)
|
||||||
ListItem(
|
for (demoteAction in availableDemoteActions) {
|
||||||
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
|
ListItem(
|
||||||
onClick = {
|
headlineContent = { Text(stringResource(demoteAction.titleRes)) },
|
||||||
sheetState.hide(coroutineScope) {
|
onClick = {
|
||||||
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator))
|
sheetState.hide(coroutineScope) {
|
||||||
}
|
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role))
|
||||||
},
|
}
|
||||||
style = ListItemStyle.Destructive,
|
},
|
||||||
)
|
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(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
|
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
|
||||||
onClick = ::dismiss,
|
onClick = ::dismiss,
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,18 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.RoomModeration
|
import im.vector.app.features.analytics.plan.RoomModeration
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
|
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
|
import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
|
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -70,6 +75,28 @@ class ChangeRoomPermissionsPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - check canChangePermissions and selectableOptions for moderator`() = runTest {
|
||||||
|
val room = FakeJoinedRoom(
|
||||||
|
baseRoom = FakeBaseRoom(
|
||||||
|
initialRoomInfo = initialRoomInfo(role = Moderator),
|
||||||
|
powerLevelsResult = { Result.success(defaultPermissions()) }
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val presenter = createChangeRoomPermissionsPresenter(room = room)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val state = awaitUpdatedItem()
|
||||||
|
assertThat(state.selectableRoles).containsExactly(SelectableRole.Moderator, SelectableRole.Everyone)
|
||||||
|
for (sectionItems in state.itemsBySection.values) {
|
||||||
|
for (permissionType in sectionItems) {
|
||||||
|
assertThat(state.canChangePermission(permissionType)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest {
|
fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest {
|
||||||
val presenter = createChangeRoomPermissionsPresenter()
|
val presenter = createChangeRoomPermissionsPresenter()
|
||||||
|
|
@ -266,7 +293,10 @@ class ChangeRoomPermissionsPresenterTest {
|
||||||
|
|
||||||
private fun createChangeRoomPermissionsPresenter(
|
private fun createChangeRoomPermissionsPresenter(
|
||||||
room: FakeJoinedRoom = FakeJoinedRoom(
|
room: FakeJoinedRoom = FakeJoinedRoom(
|
||||||
baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }),
|
baseRoom = FakeBaseRoom(
|
||||||
|
initialRoomInfo = initialRoomInfo(),
|
||||||
|
powerLevelsResult = { Result.success(defaultPermissions()) }
|
||||||
|
),
|
||||||
),
|
),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
) = ChangeRoomPermissionsPresenter(
|
) = ChangeRoomPermissionsPresenter(
|
||||||
|
|
@ -274,6 +304,13 @@ class ChangeRoomPermissionsPresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun initialRoomInfo(role: RoomMember.Role = Admin) = aRoomInfo(
|
||||||
|
roomPowerLevels = RoomPowerLevels(
|
||||||
|
values = defaultPermissions(),
|
||||||
|
users = persistentMapOf(A_SESSION_ID to role.powerLevel),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
private fun defaultPermissions() = defaultRoomPowerLevelValues()
|
private fun defaultPermissions() = defaultRoomPowerLevelValues()
|
||||||
|
|
||||||
private suspend fun TurbineTestContext<ChangeRoomPermissionsState>.awaitUpdatedItem(): ChangeRoomPermissionsState {
|
private suspend fun TurbineTestContext<ChangeRoomPermissionsState>.awaitUpdatedItem(): ChangeRoomPermissionsState {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ data class RoomMemberListState(
|
||||||
val moderationState: RoomMemberModerationState,
|
val moderationState: RoomMemberModerationState,
|
||||||
val eventSink: (RoomMemberListEvents) -> Unit,
|
val eventSink: (RoomMemberListEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
|
val showBannedSection: Boolean = moderationState.permissions.hasAny && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class SelectedSection {
|
enum class SelectedSection {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ data class RoomMemberModerationPermissions(
|
||||||
val canKick: Boolean,
|
val canKick: Boolean,
|
||||||
val canBan: Boolean,
|
val canBan: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val hasAny = canKick || canBan
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT = RoomMemberModerationPermissions(
|
val DEFAULT = RoomMemberModerationPermissions(
|
||||||
canKick = false,
|
canKick = false,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runUpdatingState
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.coroutine.mapState
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
|
|
@ -35,7 +36,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
|
||||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState
|
import io.element.android.libraries.matrix.ui.model.powerLevelOf
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
@ -56,11 +57,14 @@ class RoomMemberModerationPresenter(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): RoomMemberModerationState {
|
override fun present(): RoomMemberModerationState {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
|
||||||
val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms ->
|
val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms ->
|
||||||
perms.roomMemberModerationPermissions()
|
perms.roomMemberModerationPermissions()
|
||||||
}
|
}
|
||||||
val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value)
|
val currentUserPowerLevel by remember {
|
||||||
|
room.roomInfoFlow.mapState { info ->
|
||||||
|
info.powerLevelOf(room.sessionId)
|
||||||
|
}
|
||||||
|
}.collectAsState()
|
||||||
|
|
||||||
val kickUserAsyncAction =
|
val kickUserAsyncAction =
|
||||||
remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
|
remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
|
||||||
|
|
@ -83,7 +87,7 @@ class RoomMemberModerationPresenter(
|
||||||
moderationActions.value = computeModerationActions(
|
moderationActions.value = computeModerationActions(
|
||||||
member = member,
|
member = member,
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
currentUserMemberPowerLevel = currentUserMemberPowerLevel.value,
|
currentUserPowerLevel = currentUserPowerLevel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RoomMemberModerationEvents.ProcessAction -> {
|
is RoomMemberModerationEvents.ProcessAction -> {
|
||||||
|
|
@ -148,26 +152,26 @@ class RoomMemberModerationPresenter(
|
||||||
private fun computeModerationActions(
|
private fun computeModerationActions(
|
||||||
member: RoomMember?,
|
member: RoomMember?,
|
||||||
permissions: RoomMemberModerationPermissions,
|
permissions: RoomMemberModerationPermissions,
|
||||||
currentUserMemberPowerLevel: Long,
|
currentUserPowerLevel: Long,
|
||||||
): ImmutableList<ModerationActionState> {
|
): ImmutableList<ModerationActionState> {
|
||||||
return buildList {
|
return buildList {
|
||||||
add(ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true))
|
add(ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true))
|
||||||
// Assume the member is a regular user when it's unknown
|
// Assume the member is a regular user when it's unknown
|
||||||
val targetMemberPowerLevel = member?.powerLevel ?: 0
|
val targetMemberPowerLevel = member?.powerLevel ?: 0
|
||||||
val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel
|
val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel
|
||||||
// Assume the member is joined when it's unknown
|
// Assume the member is joined when it's unknown
|
||||||
val membership = member?.membership ?: RoomMembershipState.JOIN
|
val membership = member?.membership ?: RoomMembershipState.JOIN
|
||||||
if (permissions.canKick) {
|
if (permissions.canKick) {
|
||||||
val isKickEnabled = canModerateThisUser && membership.isActive()
|
// Unban requires kick permission instead of a dedicated unban permission
|
||||||
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled))
|
|
||||||
}
|
|
||||||
if (permissions.canBan) {
|
|
||||||
if (membership == RoomMembershipState.BAN) {
|
if (membership == RoomMembershipState.BAN) {
|
||||||
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
|
add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser))
|
||||||
} else {
|
} else if (membership != RoomMembershipState.LEAVE) {
|
||||||
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
|
add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = canModerateThisUser))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (permissions.canBan && membership != RoomMembershipState.BAN) {
|
||||||
|
add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser))
|
||||||
|
}
|
||||||
}.toImmutableList()
|
}.toImmutableList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,14 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
|
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomMember
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
|
import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues
|
||||||
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
|
@ -33,6 +36,7 @@ import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -161,7 +165,6 @@ class RoomMemberModerationPresenterTest {
|
||||||
assertThat(updatedState.selectedUser).isEqualTo(targetUser)
|
assertThat(updatedState.selectedUser).isEqualTo(targetUser)
|
||||||
assertThat(updatedState.actions).containsExactly(
|
assertThat(updatedState.actions).containsExactly(
|
||||||
ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true),
|
ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true),
|
||||||
ModerationActionState(action = ModerationAction.KickUser, isEnabled = false),
|
|
||||||
ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true),
|
ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -223,9 +226,11 @@ class RoomMemberModerationPresenterTest {
|
||||||
val room = aJoinedRoom()
|
val room = aJoinedRoom()
|
||||||
room.baseRoom.givenUpdateMembersResult {
|
room.baseRoom.givenUpdateMembersResult {
|
||||||
// Simulate the member list being updated
|
// Simulate the member list being updated
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(
|
room.givenRoomMembersState(
|
||||||
persistentListOf(aRoomMember())
|
RoomMembersState.Ready(
|
||||||
))
|
persistentListOf(aRoomMember())
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
createRoomMemberModerationPresenter(room = room).test {
|
createRoomMemberModerationPresenter(room = room).test {
|
||||||
val initialState = awaitState()
|
val initialState = awaitState()
|
||||||
|
|
@ -251,9 +256,11 @@ class RoomMemberModerationPresenterTest {
|
||||||
val room = aJoinedRoom()
|
val room = aJoinedRoom()
|
||||||
room.baseRoom.givenUpdateMembersResult {
|
room.baseRoom.givenUpdateMembersResult {
|
||||||
// Simulate the member list being updated
|
// Simulate the member list being updated
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(
|
room.givenRoomMembersState(
|
||||||
persistentListOf(aRoomMember())
|
RoomMembersState.Ready(
|
||||||
))
|
persistentListOf(aRoomMember())
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
createRoomMemberModerationPresenter(room = room).test {
|
createRoomMemberModerationPresenter(room = room).test {
|
||||||
val initialState = awaitState()
|
val initialState = awaitState()
|
||||||
|
|
@ -279,9 +286,11 @@ class RoomMemberModerationPresenterTest {
|
||||||
val room = aJoinedRoom()
|
val room = aJoinedRoom()
|
||||||
room.baseRoom.givenUpdateMembersResult {
|
room.baseRoom.givenUpdateMembersResult {
|
||||||
// Simulate the member list being updated
|
// Simulate the member list being updated
|
||||||
room.givenRoomMembersState(RoomMembersState.Ready(
|
room.givenRoomMembersState(
|
||||||
persistentListOf(aRoomMember())
|
RoomMembersState.Ready(
|
||||||
))
|
persistentListOf(aRoomMember())
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
createRoomMemberModerationPresenter(room = room).test {
|
createRoomMemberModerationPresenter(room = room).test {
|
||||||
val initialState = awaitState()
|
val initialState = awaitState()
|
||||||
|
|
@ -361,7 +370,13 @@ class RoomMemberModerationPresenterTest {
|
||||||
canKick = canKick
|
canKick = canKick
|
||||||
),
|
),
|
||||||
userRoleResult = { Result.success(myUserRole) },
|
userRoleResult = { Result.success(myUserRole) },
|
||||||
updateMembersResult = { Result.success(Unit) }
|
updateMembersResult = { Result.success(Unit) },
|
||||||
|
initialRoomInfo = aRoomInfo(
|
||||||
|
roomPowerLevels = RoomPowerLevels(
|
||||||
|
values = defaultRoomPowerLevelValues(),
|
||||||
|
users = persistentMapOf(A_USER_ID to myUserRole.powerLevel)
|
||||||
|
)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
).apply {
|
).apply {
|
||||||
val roomMembers = listOfNotNull(targetRoomMember).toImmutableList()
|
val roomMembers = listOfNotNull(targetRoomMember).toImmutableList()
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ private fun PreferenceBlockUser(
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
eventSink: (UserProfileEvents) -> Unit,
|
eventSink: (UserProfileEvents) -> Unit,
|
||||||
) {
|
) {
|
||||||
val loadingCurrentValue = @Composable {
|
val loadingCurrentValue = @Composable { _: Boolean ->
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.progressSemantics()
|
.progressSemantics()
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ sealed interface ListItemContent {
|
||||||
data class Text(val text: String) : ListItemContent
|
data class Text(val text: String) : ListItemContent
|
||||||
|
|
||||||
/** Displays any custom content. */
|
/** Displays any custom content. */
|
||||||
data class Custom(val content: @Composable () -> Unit) : ListItemContent
|
data class Custom(val content: @Composable (enabled: Boolean) -> Unit) : ListItemContent
|
||||||
|
|
||||||
/** Displays a badge. */
|
/** Displays a badge. */
|
||||||
data object Badge : ListItemContent
|
data object Badge : ListItemContent
|
||||||
|
|
@ -131,7 +131,7 @@ sealed interface ListItemContent {
|
||||||
is Counter -> {
|
is Counter -> {
|
||||||
CounterAtom(count = count)
|
CounterAtom(count = count)
|
||||||
}
|
}
|
||||||
is Custom -> content()
|
is Custom -> content(isItemEnabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ fun PreferenceCheckbox(
|
||||||
leadingContent = preferenceIcon(
|
leadingContent = preferenceIcon(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconResourceId = iconResourceId,
|
iconResourceId = iconResourceId,
|
||||||
enabled = enabled,
|
|
||||||
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
||||||
),
|
),
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.DropdownMenuIt
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.toEnabledColor
|
import io.element.android.libraries.designsystem.toIconSecondaryEnabledColor
|
||||||
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
|
import io.element.android.libraries.designsystem.toSecondaryEnabledColor
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
@ -64,7 +64,6 @@ fun <T : DropdownOption> PreferenceDropdown(
|
||||||
leadingContent = preferenceIcon(
|
leadingContent = preferenceIcon(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconResourceId = iconResourceId,
|
iconResourceId = iconResourceId,
|
||||||
enabled = enabled,
|
|
||||||
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
||||||
),
|
),
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
@ -72,7 +71,6 @@ fun <T : DropdownOption> PreferenceDropdown(
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
text = title,
|
text = title,
|
||||||
color = enabled.toEnabledColor(),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = supportingText?.let {
|
supportingContent = supportingText?.let {
|
||||||
|
|
@ -80,22 +78,23 @@ fun <T : DropdownOption> PreferenceDropdown(
|
||||||
Text(
|
Text(
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
text = it,
|
text = it,
|
||||||
color = enabled.toSecondaryEnabledColor(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = ListItemContent.Custom(
|
trailingContent = ListItemContent.Custom(
|
||||||
content = {
|
content = { enabled ->
|
||||||
DropdownTrailingContent(
|
DropdownTrailingContent(
|
||||||
selectedOption = selectedOption,
|
selectedOption = selectedOption,
|
||||||
options = options,
|
options = options,
|
||||||
onSelectOption = onSelectOption,
|
onSelectOption = onSelectOption,
|
||||||
expanded = isDropdownExpanded,
|
expanded = isDropdownExpanded,
|
||||||
onExpandedChange = { isDropdownExpanded = it },
|
onExpandedChange = { isDropdownExpanded = it },
|
||||||
|
enabled = enabled,
|
||||||
modifier = Modifier.fillMaxSize(0.3f)
|
modifier = Modifier.fillMaxSize(0.3f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
enabled = enabled,
|
||||||
onClick = { isDropdownExpanded = true }.takeIf { !isDropdownExpanded },
|
onClick = { isDropdownExpanded = true }.takeIf { !isDropdownExpanded },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -118,6 +117,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
onExpandedChange: (Boolean) -> Unit,
|
||||||
onSelectOption: (T) -> Unit,
|
onSelectOption: (T) -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -129,7 +129,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
|
||||||
text = selectedOption?.getText().orEmpty(),
|
text = selectedOption?.getText().orEmpty(),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = enabled.toSecondaryEnabledColor(),
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
@ -137,7 +137,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = CompoundIcons.ChevronDown(),
|
imageVector = CompoundIcons.ChevronDown(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = ElementTheme.colors.iconSecondary,
|
tint = enabled.toIconSecondaryEnabledColor(),
|
||||||
)
|
)
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
|
|
@ -146,6 +146,7 @@ private fun <T : DropdownOption> DropdownTrailingContent(
|
||||||
) {
|
) {
|
||||||
options.forEach { option ->
|
options.forEach { option ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
enabled = enabled,
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
text = option.getText(),
|
text = option.getText(),
|
||||||
|
|
@ -206,5 +207,14 @@ internal fun PreferenceDropdownPreview() = ElementThemedPreview {
|
||||||
options = options,
|
options = options,
|
||||||
onSelectOption = {},
|
onSelectOption = {},
|
||||||
)
|
)
|
||||||
|
PreferenceDropdown(
|
||||||
|
title = "Dropdown",
|
||||||
|
supportingText = "Options for dropdown",
|
||||||
|
icon = CompoundIcons.Threads(),
|
||||||
|
selectedOption = options.first(),
|
||||||
|
options = options,
|
||||||
|
onSelectOption = {},
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ fun PreferenceSlide(
|
||||||
leadingContent = preferenceIcon(
|
leadingContent = preferenceIcon(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconResourceId = iconResourceId,
|
iconResourceId = iconResourceId,
|
||||||
enabled = enabled,
|
|
||||||
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
||||||
),
|
),
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ fun PreferenceSwitch(
|
||||||
leadingContent = preferenceIcon(
|
leadingContent = preferenceIcon(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconResourceId = iconResourceId,
|
iconResourceId = iconResourceId,
|
||||||
enabled = enabled,
|
|
||||||
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
showIconAreaIfNoIcon = showIconAreaIfNoIcon,
|
||||||
),
|
),
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,10 @@ fun preferenceIcon(
|
||||||
@DrawableRes iconResourceId: Int? = null,
|
@DrawableRes iconResourceId: Int? = null,
|
||||||
showIconBadge: Boolean = false,
|
showIconBadge: Boolean = false,
|
||||||
tintColor: Color? = null,
|
tintColor: Color? = null,
|
||||||
enabled: Boolean = true,
|
|
||||||
showIconAreaIfNoIcon: Boolean = false,
|
showIconAreaIfNoIcon: Boolean = false,
|
||||||
): ListItemContent.Custom? {
|
): ListItemContent.Custom? {
|
||||||
return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) {
|
return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) {
|
||||||
ListItemContent.Custom {
|
ListItemContent.Custom { enabled ->
|
||||||
PreferenceIcon(
|
PreferenceIcon(
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconResourceId = iconResourceId,
|
iconResourceId = iconResourceId,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,20 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
|
||||||
size = size,
|
size = size,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the power level of the user in the room.
|
||||||
|
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns the power level of [RoomMember.Role.Owner].
|
||||||
|
* Otherwise, checks the room's power levels for the user's power level.
|
||||||
|
* If no specific power level is set for the user, defaults to 0.
|
||||||
|
*/
|
||||||
|
fun RoomInfo.powerLevelOf(userId: UserId): Long {
|
||||||
|
return if (privilegedCreatorRole && creators.contains(userId)) {
|
||||||
|
RoomMember.Role.Owner(isCreator = true).powerLevel
|
||||||
|
} else {
|
||||||
|
roomPowerLevels?.powerLevelOf(userId = userId) ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the role of the user in the room.
|
* Returns the role of the user in the room.
|
||||||
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner].
|
* If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner].
|
||||||
|
|
@ -28,9 +42,6 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
|
||||||
* If no specific power level is set for the user, defaults to [RoomMember.Role.User].
|
* If no specific power level is set for the user, defaults to [RoomMember.Role.User].
|
||||||
*/
|
*/
|
||||||
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
|
fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
|
||||||
return if (privilegedCreatorRole && creators.contains(userId)) {
|
val powerLevel = powerLevelOf(userId = userId)
|
||||||
RoomMember.Role.Owner(isCreator = true)
|
return RoomMember.Role.forPowerLevel(powerLevel)
|
||||||
} else {
|
|
||||||
roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2023-2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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.BaseRoom
|
|
||||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
|
||||||
import io.element.android.libraries.matrix.ui.model.roleOf
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BaseRoom.userPowerLevelAsState(updateKey: Long): State<Long> {
|
|
||||||
return produceState(initialValue = 0, key1 = updateKey) {
|
|
||||||
value = userRole(sessionId)
|
|
||||||
.getOrDefault(RoomMember.Role.User)
|
|
||||||
.powerLevel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BaseRoom.isOwnUserAdmin(): Boolean {
|
|
||||||
val roomInfo by roomInfoFlow.collectAsState()
|
|
||||||
val role = roomInfo.roleOf(sessionId)
|
|
||||||
return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f82f648e9f5d67abfe2cf783df7e70568a74ff5dcae876e80b54995981a65886
|
oid sha256:12c69b646ec09afdbc6e90873ee831adbd62ea7c45679c54620f74a9bd74941c
|
||||||
size 49650
|
size 48321
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0ebf86fc0ac80375edd1730caab4a669aa2cc545b2ea946981901fd360114320
|
oid sha256:f82f648e9f5d67abfe2cf783df7e70568a74ff5dcae876e80b54995981a65886
|
||||||
size 44555
|
size 49650
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fae74539f53342110e50a077ad78b8422d08ba931b948b2b167d90f6cbe8ade6
|
oid sha256:0ebf86fc0ac80375edd1730caab4a669aa2cc545b2ea946981901fd360114320
|
||||||
size 43535
|
size 44555
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:61bced1ea61f5952e83956b4c4447a0371fe99c874e2a20df431bc59e02dc838
|
oid sha256:fae74539f53342110e50a077ad78b8422d08ba931b948b2b167d90f6cbe8ade6
|
||||||
size 50316
|
size 43535
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:73d9461a1d51964ba1a912376658e3e550e952821f7aa1d9bff765ed11deb920
|
oid sha256:61bced1ea61f5952e83956b4c4447a0371fe99c874e2a20df431bc59e02dc838
|
||||||
size 49123
|
size 50316
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:73d9461a1d51964ba1a912376658e3e550e952821f7aa1d9bff765ed11deb920
|
||||||
|
size 49123
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:192fce45ea8d83d8f7aa40f9337b2dd92c3b1194da611fed1c42fd771d74fe0a
|
oid sha256:e72874066861ce76c76134dd84b9ab058ea3c7ca1c54176f8863c6bc1b8226fc
|
||||||
size 48534
|
size 47273
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b3ed16d04a1106b82183c4fe6ae741b598448050e2ddae2fa842b0b01d475e0a
|
oid sha256:192fce45ea8d83d8f7aa40f9337b2dd92c3b1194da611fed1c42fd771d74fe0a
|
||||||
size 43230
|
size 48534
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0cb2f7d7cad98d111ed2c1c0b8120e5837f2586f845594e581b41b7cb96e00f2
|
oid sha256:b3ed16d04a1106b82183c4fe6ae741b598448050e2ddae2fa842b0b01d475e0a
|
||||||
size 41555
|
size 43230
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0862da8d120e276afd6bdef6a9b247d3976ae70adab47077d8749600dd695884
|
oid sha256:0cb2f7d7cad98d111ed2c1c0b8120e5837f2586f845594e581b41b7cb96e00f2
|
||||||
size 48280
|
size 41555
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:48e5f7460a13798d2f2963930e4e7a95c6a1ffa704d034ec21e33ba1bde6606c
|
oid sha256:0862da8d120e276afd6bdef6a9b247d3976ae70adab47077d8749600dd695884
|
||||||
size 48123
|
size 48280
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:48e5f7460a13798d2f2963930e4e7a95c6a1ffa704d034ec21e33ba1bde6606c
|
||||||
|
size 48123
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:00eb9c6756c353ac82144c391617a515d871940c32d0604d86203f8568e9e5e2
|
oid sha256:ed643834f8fd2bc167dfd95113ab9098392f4d626ccdd58c9fae2cfd264f70c5
|
||||||
size 32077
|
size 41611
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue