feature(security&privacy): iterate on SpaceMember option

This commit is contained in:
ganfra 2026-01-06 15:15:38 +01:00
parent 96745c765a
commit 1930877a81
13 changed files with 226 additions and 88 deletions

View file

@ -12,12 +12,14 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
@ -31,12 +33,15 @@ import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
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.powerlevels.use import io.element.android.libraries.matrix.api.room.powerlevels.use
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -61,7 +66,7 @@ class SecurityAndPrivacyFlowNode(
data object EditRoomAddress : NavTarget data object EditRoomAddress : NavTarget
@Parcelize @Parcelize
data object ManageAuthorizedSpaces : NavTarget data class ManageAuthorizedSpaces(val initialSelection: List<RoomId>) : NavTarget
} }
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback() private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
@ -83,6 +88,18 @@ class SecurityAndPrivacyFlowNode(
callback.onDone() callback.onDone()
} }
} }
whenChildrenAttached { commonLifecycle: Lifecycle,
securityAndPrivacyNode: SecurityAndPrivacyNode,
manageAuthorizedSpacesNode: ManageAuthorizedSpacesNode ->
commonLifecycle.coroutineScope.launch {
val authorizedSpacesData = securityAndPrivacyNode.getAuthorizedSpacesData()
val selectedSpaces = manageAuthorizedSpacesNode.waitForCompletion(authorizedSpacesData)
withContext(NonCancellable) {
backstack.pop()
securityAndPrivacyNode.onAuthorizedSpacesSelected(selectedSpaces)
}
}
}
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -93,7 +110,7 @@ class SecurityAndPrivacyFlowNode(
NavTarget.EditRoomAddress -> { NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator)) createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
} }
NavTarget.ManageAuthorizedSpaces -> { is NavTarget.ManageAuthorizedSpaces -> {
createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator)) createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator))
} }
} }

View file

@ -13,12 +13,13 @@ import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface SecurityAndPrivacyNavigator : Plugin { interface SecurityAndPrivacyNavigator : Plugin {
fun onDone() fun onDone()
fun openEditRoomAddress() fun openEditRoomAddress()
fun closeEditRoomAddress() fun closeEditRoomAddress()
fun openManageAuthorizedSpaces() fun openManageAuthorizedSpaces(initialSelection: List<RoomId>)
fun closeManageAuthorizedSpaces() fun closeManageAuthorizedSpaces()
} }
@ -38,8 +39,8 @@ class BackstackSecurityAndPrivacyNavigator(
backStack.pop() backStack.pop()
} }
override fun openManageAuthorizedSpaces() { override fun openManageAuthorizedSpaces(initialSelection: List<RoomId>) {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces) backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(initialSelection))
} }
override fun closeManageAuthorizedSpaces() { override fun closeManageAuthorizedSpaces() {

View file

@ -11,6 +11,7 @@ package io.element.android.features.securityandprivacy.impl.manageauthorizedspac
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface ManageAuthorizedSpacesEvent { sealed interface ManageAuthorizedSpacesEvent {
data class SetData(val data: AuthorizedSpacesSelection) : ManageAuthorizedSpacesEvent
data object Done : ManageAuthorizedSpacesEvent data object Done : ManageAuthorizedSpacesEvent
data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent
} }

View file

@ -9,6 +9,8 @@
package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
@ -18,7 +20,12 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -27,12 +34,24 @@ class ManageAuthorizedSpacesNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: ManageAuthorizedSpacesPresenter.Factory, presenterFactory: ManageAuthorizedSpacesPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Params(
val initialSelection: List<RoomId>
) : NodeInputs
private val navigator = plugins<SecurityAndPrivacyNavigator>().first() private val navigator = plugins<SecurityAndPrivacyNavigator>().first()
private val presenter = presenterFactory.create(navigator) private val presenter = presenterFactory.create(navigator)
private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForCompletion(data: AuthorizedSpacesSelection): ImmutableList<RoomId> {
stateFlow.value.eventSink(ManageAuthorizedSpacesEvent.SetData(data))
return stateFlow.first { it.isSelectionComplete }.selectedIds
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state by stateFlow.collectAsState()
ManageAuthorizedSpacesView( ManageAuthorizedSpacesView(
state = state, state = state,
onBackClick = ::navigateUp, onBackClick = ::navigateUp,

View file

@ -9,16 +9,21 @@
package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@AssistedInject @AssistedInject
class ManageAuthorizedSpacesPresenter( class ManageAuthorizedSpacesPresenter(
@ -33,19 +38,33 @@ class ManageAuthorizedSpacesPresenter(
@Composable @Composable
override fun present(): ManageAuthorizedSpacesState { override fun present(): ManageAuthorizedSpacesState {
val roomInfo by room.roomInfoFlow.collectAsState() var currentSelection: ImmutableList<RoomId> by remember { mutableStateOf(persistentListOf()) }
var spacesData by remember { mutableStateOf(AuthorizedSpacesSelection()) }
var isSelectionComplete by remember { mutableStateOf(false) }
fun handleEvent(event: ManageAuthorizedSpacesEvent) { fun handleEvent(event: ManageAuthorizedSpacesEvent) {
when (event) { when (event) {
ManageAuthorizedSpacesEvent.Done -> TODO() ManageAuthorizedSpacesEvent.Done -> {
is ManageAuthorizedSpacesEvent.ToggleSpace -> TODO() isSelectionComplete = true
}
is ManageAuthorizedSpacesEvent.ToggleSpace -> {
currentSelection = if (currentSelection.contains(event.roomId)) {
currentSelection.minus(event.roomId).toPersistentList()
} else {
currentSelection.plus(event.roomId).toPersistentList()
}
}
is ManageAuthorizedSpacesEvent.SetData -> {
spacesData = event.data
currentSelection = event.data.initialSelectedIds
}
} }
} }
return ManageAuthorizedSpacesState( return ManageAuthorizedSpacesState(
joinedSpaces = persistentListOf(), selection = spacesData,
unknownSpaceIds = persistentListOf(), selectedIds = currentSelection,
currentSelection = persistentListOf(), isSelectionComplete = isSelectionComplete,
initialSelection = persistentListOf(),
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -11,11 +11,17 @@ package io.element.android.features.securityandprivacy.impl.manageauthorizedspac
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class ManageAuthorizedSpacesState( data class ManageAuthorizedSpacesState(
val joinedSpaces: ImmutableList<SpaceRoom>, val selection: AuthorizedSpacesSelection,
val unknownSpaceIds: ImmutableList<RoomId>, val selectedIds: ImmutableList<RoomId>,
val currentSelection: ImmutableList<RoomId>, val isSelectionComplete: Boolean,
val initialSelection: ImmutableList<RoomId>,
val eventSink: (ManageAuthorizedSpacesEvent) -> Unit val eventSink: (ManageAuthorizedSpacesEvent) -> Unit
) )
data class AuthorizedSpacesSelection(
val joinedSpaces: ImmutableList<SpaceRoom> = persistentListOf(),
val unknownSpaceIds: ImmutableList<RoomId> = persistentListOf(),
val initialSelectedIds: ImmutableList<RoomId> = persistentListOf()
)

View file

@ -20,11 +20,15 @@ open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider<Manage
get() = sequenceOf( get() = sequenceOf(
aManageAuthorizedSpacesState(), aManageAuthorizedSpacesState(),
aManageAuthorizedSpacesState( aManageAuthorizedSpacesState(
unknownSpaceIds = listOf(aRoomId(99)) authorizedSpacesData = anAuthorizedSpacesData(
unknownSpaceIds = listOf(aRoomId(99))
)
), ),
aManageAuthorizedSpacesState( aManageAuthorizedSpacesState(
currentSelection = listOf(aRoomId(1), aRoomId(3)), currentSelection = listOf(aRoomId(1), aRoomId(3)),
initialSelection = listOf(aRoomId(1)), authorizedSpacesData = anAuthorizedSpacesData(
initialSelection = listOf(aRoomId(1)),
),
), ),
) )
} }
@ -45,17 +49,24 @@ private fun aSpaceRoomList(count: Int): List<SpaceRoom> {
} }
} }
private fun aManageAuthorizedSpacesState( fun anAuthorizedSpacesData(
joinedSpaces: List<SpaceRoom> = aSpaceRoomList(5), joinedSpaces: List<SpaceRoom> = aSpaceRoomList(5),
unknownSpaceIds: List<RoomId> = emptyList(), unknownSpaceIds: List<RoomId> = emptyList(),
currentSelection: List<RoomId> = emptyList(),
initialSelection: List<RoomId> = emptyList(), initialSelection: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, ) = AuthorizedSpacesSelection(
) = ManageAuthorizedSpacesState(
joinedSpaces = joinedSpaces.toImmutableList(), joinedSpaces = joinedSpaces.toImmutableList(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(), unknownSpaceIds = unknownSpaceIds.toImmutableList(),
currentSelection = currentSelection.toImmutableList(), initialSelectedIds = initialSelection.toImmutableList(),
initialSelection = initialSelection.toImmutableList(), )
private fun aManageAuthorizedSpacesState(
authorizedSpacesData: AuthorizedSpacesSelection = anAuthorizedSpacesData(),
currentSelection: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState(
selection = authorizedSpacesData,
selectedIds = currentSelection.toImmutableList(),
isSelectionComplete = false,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -66,12 +66,12 @@ fun ManageAuthorizedSpacesView(
hasDivider = false, hasDivider = false,
) )
} }
items(items = state.joinedSpaces) { space -> items(items = state.selection.joinedSpaces) { space ->
CheckableSpaceListItem( CheckableSpaceListItem(
headlineText = space.displayName, headlineText = space.displayName,
supportingText = space.canonicalAlias?.value, supportingText = space.canonicalAlias?.value,
avatarData = space.getAvatarData(AvatarSize.SpaceMember), avatarData = space.getAvatarData(AvatarSize.SpaceMember),
checked = state.currentSelection.contains(space.roomId), checked = state.selectedIds.contains(space.roomId),
onCheckedChange = { _ -> onCheckedChange = { _ ->
state.eventSink( state.eventSink(
ManageAuthorizedSpacesEvent.ToggleSpace(space.roomId) ManageAuthorizedSpacesEvent.ToggleSpace(space.roomId)
@ -79,19 +79,19 @@ fun ManageAuthorizedSpacesView(
} }
) )
} }
if (state.unknownSpaceIds.isNotEmpty()) { if (state.selection.unknownSpaceIds.isNotEmpty()) {
item { item {
ListSectionHeader( ListSectionHeader(
title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title), title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title),
hasDivider = true, hasDivider = true,
) )
} }
items(items = state.unknownSpaceIds) { items(items = state.selection.unknownSpaceIds) {
CheckableSpaceListItem( CheckableSpaceListItem(
headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space), headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space),
supportingText = it.value, supportingText = it.value,
avatarData = null, avatarData = null,
checked = state.currentSelection.contains(it), checked = state.selectedIds.contains(it),
onCheckedChange = { _ -> onCheckedChange = { _ ->
state.eventSink( state.eventSink(
ManageAuthorizedSpacesEvent.ToggleSpace(it) ManageAuthorizedSpacesEvent.ToggleSpace(it)

View file

@ -23,9 +23,12 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.AuthorizedSpacesSelection
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -43,6 +46,16 @@ class SecurityAndPrivacyNode(
activity.openUrlInChromeCustomTab(null, darkTheme, url) activity.openUrlInChromeCustomTab(null, darkTheme, url)
} }
fun getAuthorizedSpacesData(): AuthorizedSpacesSelection{
return stateFlow.value.getAuthorizedSpaceData()
}
fun onAuthorizedSpacesSelected(selectedSpaces: ImmutableList<RoomId>) {
stateFlow.value.eventSink(
SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.SpaceMember(selectedSpaces))
)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)
@ -56,4 +69,5 @@ class SecurityAndPrivacyNode(
modifier = modifier modifier = modifier
) )
} }
} }

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
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.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
@ -130,9 +131,9 @@ class SecurityAndPrivacyPresenter(
value = (joinedParentSpaces + nonParentJoinedSpaces).toImmutableSet() value = (joinedParentSpaces + nonParentJoinedSpaces).toImmutableSet()
} }
val spaceSelection by remember { val spaceSelectionMode by remember {
derivedStateOf { derivedStateOf {
getSpaceSelection(selectableJoinedSpaces, savedSettings.roomAccess) getSpaceSelectionMode(selectableJoinedSpaces, savedSettings.roomAccess)
} }
} }
@ -191,9 +192,12 @@ class SecurityAndPrivacyPresenter(
SecurityAndPrivacyEvent.DismissExitConfirmation -> { SecurityAndPrivacyEvent.DismissExitConfirmation -> {
saveAction.value = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> navigator.openManageAuthorizedSpaces() SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> {
navigator.openManageAuthorizedSpaces(editedSettings.roomAccess.spaceIds())
}
SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> handleSpaceMemberAccessSelection( SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> handleSpaceMemberAccessSelection(
spaceSelection = spaceSelection, spaceSelectionMode = spaceSelectionMode,
spaceIds = editedSettings.roomAccess.spaceIds(),
editedAccess = editedRoomAccess, editedAccess = editedRoomAccess,
) )
} }
@ -216,7 +220,7 @@ class SecurityAndPrivacyPresenter(
isSpace = roomInfo.isSpace, isSpace = roomInfo.isSpace,
isSpaceSettingsEnabled = isSpaceSettingsEnabled, isSpaceSettingsEnabled = isSpaceSettingsEnabled,
selectableJoinedSpaces = selectableJoinedSpaces, selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelection = spaceSelection, spaceSelectionMode = spaceSelectionMode,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
@ -241,42 +245,45 @@ class SecurityAndPrivacyPresenter(
} }
private fun handleSpaceMemberAccessSelection( private fun handleSpaceMemberAccessSelection(
spaceSelection: SpaceSelection, spaceSelectionMode: SpaceSelectionMode,
spaceIds: List<RoomId>,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>, editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) { ) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) { if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) {
return return
} }
when (spaceSelection) { when (spaceSelectionMode) {
is SpaceSelection.None -> Unit is SpaceSelectionMode.None -> Unit
is SpaceSelection.Multiple -> navigator.openManageAuthorizedSpaces() is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces(
is SpaceSelection.Single -> { initialSelection = spaceIds ,
)
is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember( val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = persistentListOf(spaceSelection.spaceId) spaceIds = persistentListOf(spaceSelectionMode.spaceId)
) )
editedAccess.value = newRoomAccess editedAccess.value = newRoomAccess
} }
} }
} }
private fun getSpaceSelection( private fun getSpaceSelectionMode(
selectableJoinedSpaces: Set<SpaceRoom>, selectableJoinedSpaces: Set<SpaceRoom>,
savedAccess: SecurityAndPrivacyRoomAccess, savedAccess: SecurityAndPrivacyRoomAccess,
): SpaceSelection { ): SpaceSelectionMode {
val selectableSpacesCount = (selectableJoinedSpaces.map { it.roomId } + savedAccess.spaceIds()).toSet().size val selectableSpacesCount = (selectableJoinedSpaces.map { it.roomId } + savedAccess.spaceIds()).toSet().size
return when { return when {
selectableSpacesCount == 0 -> SpaceSelection.None selectableSpacesCount == 0 -> SpaceSelectionMode.None
selectableSpacesCount > 1 -> SpaceSelection.Multiple selectableSpacesCount > 1 -> SpaceSelectionMode.Multiple
else -> { else -> {
val joinedSpace = selectableJoinedSpaces.firstOrNull() val joinedSpace = selectableJoinedSpaces.firstOrNull()
if (joinedSpace != null) { if (joinedSpace != null) {
SpaceSelection.Single(joinedSpace.roomId, joinedSpace) SpaceSelectionMode.Single(joinedSpace.roomId, joinedSpace)
} else { } else {
val spaceId = savedAccess.spaceIds().firstOrNull() val spaceId = savedAccess.spaceIds().firstOrNull()
if (spaceId == null) { if (spaceId == null) {
SpaceSelection.None SpaceSelectionMode.None
} else { } else {
SpaceSelection.Single(spaceId, null) SpaceSelectionMode.Single(spaceId, null)
} }
} }
} }

View file

@ -8,7 +8,11 @@
package io.element.android.features.securityandprivacy.impl.root package io.element.android.features.securityandprivacy.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.R
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.AuthorizedSpacesSelection
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -25,16 +29,32 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings, val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String, val homeserverName: String,
val showEnableEncryptionConfirmation: Boolean, val showEnableEncryptionConfirmation: Boolean,
val isKnockEnabled: Boolean, private val isKnockEnabled: Boolean,
val isSpaceSettingsEnabled: Boolean, private val isSpaceSettingsEnabled: Boolean,
val saveAction: AsyncAction<Unit>, val saveAction: AsyncAction<Unit>,
val isSpace: Boolean, val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions, private val permissions: SecurityAndPrivacyPermissions,
private val selectableJoinedSpaces: ImmutableSet<SpaceRoom>, private val selectableJoinedSpaces: ImmutableSet<SpaceRoom>,
private val spaceSelection: SpaceSelection, private val spaceSelectionMode: SpaceSelectionMode,
val eventSink: (SecurityAndPrivacyEvent) -> Unit val eventSink: (SecurityAndPrivacyEvent) -> Unit
) { ) {
val isSpaceMemberSelectable = isSpaceSettingsEnabled && spaceSelectionMode != SpaceSelectionMode.None
// Show SpaceMember option in two cases:
// - the SpaceSettings FF is enabled
// - SpaceMember is the current saved value
val showSpaceMemberOption = savedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember || isSpaceMemberSelectable
val showManageSpaceAction = spaceSelectionMode is SpaceSelectionMode.Multiple && editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember
val isAskToJoinSelectable = isKnockEnabled
// Show Ask to join option in two cases:
// - the Knock FF is enabled
// - AskToJoin is the current saved value
val showAskToJoinOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.AskToJoin || isAskToJoinSelectable
val canBeSaved = savedSettings != editedSettings val canBeSaved = savedSettings != editedSettings
// Logic is in https://github.com/element-hq/element-meta/issues/3029 // Logic is in https://github.com/element-hq/element-meta/issues/3029
@ -57,6 +77,32 @@ data class SecurityAndPrivacyState(
val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace
val showEncryptionSection = permissions.canChangeEncryption && !isSpace val showEncryptionSection = permissions.canChangeEncryption && !isSpace
@Composable
fun spaceMemberDescription(): String {
return if (isSpaceMemberSelectable) {
when (spaceSelectionMode) {
is SpaceSelectionMode.Single -> {
val spaceName = spaceSelectionMode.spaceRoom?.displayName ?: spaceSelectionMode.spaceId.value
stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_single_parent_description, spaceName)
}
is SpaceSelectionMode.None,
is SpaceSelectionMode.Multiple -> stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_multiple_parents_description)
}
} else {
stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)
}
}
fun getAuthorizedSpaceData(): AuthorizedSpacesSelection {
return AuthorizedSpacesSelection(
joinedSpaces = selectableJoinedSpaces.toImmutableList(),
unknownSpaceIds = savedSettings.roomAccess.spaceIds().filter { spaceId ->
selectableJoinedSpaces.none { it.roomId == spaceId }
}.toImmutableList(),
initialSelectedIds = editedSettings.roomAccess.spaceIds().toImmutableList()
)
}
} }
data class SecurityAndPrivacySettings( data class SecurityAndPrivacySettings(
@ -85,10 +131,10 @@ enum class SecurityAndPrivacyHistoryVisibility {
} }
} }
sealed interface SpaceSelection { sealed interface SpaceSelectionMode {
data object None : SpaceSelection data object None : SpaceSelectionMode
data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelection data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelectionMode
data object Multiple : SpaceSelection data object Multiple : SpaceSelectionMode
} }
sealed interface SecurityAndPrivacyRoomAccess { sealed interface SecurityAndPrivacyRoomAccess {

View file

@ -14,7 +14,6 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> { open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
@ -122,7 +121,7 @@ fun aSecurityAndPrivacyState(
isKnockEnabled: Boolean = true, isKnockEnabled: Boolean = true,
isSpace: Boolean = false, isSpace: Boolean = false,
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(), selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelection: SpaceSelection = SpaceSelection.None, spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isSpaceSettingsEnabled: Boolean = true, isSpaceSettingsEnabled: Boolean = true,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {} eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState( ) = SecurityAndPrivacyState(
@ -135,7 +134,7 @@ fun aSecurityAndPrivacyState(
permissions = permissions, permissions = permissions,
isSpace = isSpace, isSpace = isSpace,
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelection = SpaceSelection.None, spaceSelectionMode = spaceSelectionMode,
isSpaceSettingsEnabled = isSpaceSettingsEnabled, isSpaceSettingsEnabled = isSpaceSettingsEnabled,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -90,14 +90,8 @@ fun SecurityAndPrivacyView(
) { ) {
if (state.showRoomAccessSection) { if (state.showRoomAccessSection) {
RoomAccessSection( RoomAccessSection(
state = state,
modifier = Modifier.padding(top = 24.dp), modifier = Modifier.padding(top = 24.dp),
edited = state.editedSettings.roomAccess,
saved = state.savedSettings.roomAccess,
isKnockEnabled = state.isKnockEnabled,
isSpaceSettingsEnabled = state.isSpaceSettingsEnabled,
onSelectOption = { state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(it)) },
onManageSpacesClick = { state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces) },
onSpaceMemberAccessClick = { state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) }
) )
} }
if (state.showRoomVisibilitySections) { if (state.showRoomVisibilitySections) {
@ -211,15 +205,25 @@ private fun SecurityAndPrivacySection(
@Composable @Composable
private fun RoomAccessSection( private fun RoomAccessSection(
edited: SecurityAndPrivacyRoomAccess, state: SecurityAndPrivacyState,
saved: SecurityAndPrivacyRoomAccess,
isKnockEnabled: Boolean,
isSpaceSettingsEnabled: Boolean,
onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit,
onSpaceMemberAccessClick: () -> Unit,
onManageSpacesClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val edited = state.editedSettings.roomAccess
val saved = state.savedSettings.roomAccess
fun onSelectOption(option: SecurityAndPrivacyRoomAccess) {
state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(option))
}
fun onSpaceMemberAccessClick() {
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
}
fun onManageSpacesClick() {
state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces)
}
SecurityAndPrivacySection( SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier, modifier = modifier,
@ -231,31 +235,25 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
) )
// Show SpaceMember option in two cases: if (state.showSpaceMemberOption)
// - the SpaceSettings FF is enabled
// - SpaceMember is the current saved value
if (saved is SecurityAndPrivacyRoomAccess.SpaceMember || isSpaceSettingsEnabled)
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = { supportingContent = {
Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)) Text(text = state.spaceMemberDescription())
}, },
trailingContent = ListItemContent.RadioButton(selected = edited is SecurityAndPrivacyRoomAccess.SpaceMember), trailingContent = ListItemContent.RadioButton(selected = state.editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())),
onClick = onSpaceMemberAccessClick, onClick = ::onSpaceMemberAccessClick,
enabled = isSpaceSettingsEnabled, enabled = state.isSpaceMemberSelectable,
) )
// Show Ask to join option in two cases: if (state.showAskToJoinOption) {
// - the Knock FF is enabled
// - AskToJoin is the current saved value
if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) {
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin), trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
enabled = isKnockEnabled, enabled = state.isAskToJoinSelectable,
) )
} }
ListItem( ListItem(
@ -265,11 +263,11 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) },
) )
if (edited is SecurityAndPrivacyRoomAccess.SpaceMember) { if (state.showManageSpaceAction) {
val footerText = stringWithLink( val footerText = stringWithLink(
textRes = R.string.screen_security_and_privacy_room_access_footer, textRes = R.string.screen_security_and_privacy_room_access_footer,
url = stringResource(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action), url = stringResource(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action),
onLinkClick = { onManageSpacesClick() }, onLinkClick = {onManageSpacesClick()},
) )
Text( Text(
text = footerText, text = footerText,