Merge pull request #5354 from element-hq/feature/bma/leaveSpace

Leave space - UI
This commit is contained in:
Benoit Marty 2025-09-26 16:58:53 +02:00 committed by GitHub
commit d096256a41
75 changed files with 1281 additions and 98 deletions

View file

@ -31,9 +31,17 @@ class LoggedInEventProcessor(
observingJob = roomMembershipObserver.updates observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom } .filter { !it.isUserInRoom }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach { roomMemberShipUpdate ->
when (it.change) { when (roomMemberShipUpdate.change) {
MembershipChange.LEFT -> displayMessage(CommonStrings.common_current_user_left_room) MembershipChange.LEFT -> {
displayMessage(
if (roomMemberShipUpdate.isSpace) {
CommonStrings.common_current_user_left_space
} else {
CommonStrings.common_current_user_left_room
}
)
}
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite) MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock) MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit else -> Unit

View file

@ -14,5 +14,7 @@
<string name="screen_bug_report_include_screenshot">"Send screenshot"</string> <string name="screen_bug_report_include_screenshot">"Send screenshot"</string>
<string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string> <string name="screen_bug_report_logs_description">"Logs will be included with your message to make sure that everything is working properly. To send your message without logs, turn off this setting."</string>
<string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string> <string name="screen_bug_report_rash_logs_alert_title">"%1$s crashed the last time it was used. Would you like to share a crash report with us?"</string>
<string name="screen_bug_report_send_notification_settings_description">"If you are having issues with notifications, uploading the notification settings can help us pinpoint the root cause."</string>
<string name="screen_bug_report_send_notification_settings_title">"Send notification settings"</string>
<string name="screen_bug_report_view_logs">"View logs"</string> <string name="screen_bug_report_view_logs">"View logs"</string>
</resources> </resources>

View file

@ -33,7 +33,7 @@ class DefaultSpaceEntryPoint : SpaceEntryPoint {
} }
override fun build(): Node { override fun build(): Node {
return parentNode.createNode<SpaceNode>(buildContext, plugins = plugins.toList()) return parentNode.createNode<SpaceFlowNode>(buildContext, plugins = plugins.toList())
} }
} }
} }

View file

@ -0,0 +1,81 @@
/*
* Copyright 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.space.impl
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.Inject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.features.space.impl.leave.LeaveSpaceNode
import io.element.android.features.space.impl.root.SpaceNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@Inject
class SpaceFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<SpaceFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object Leave : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Leave -> {
createNode<LeaveSpaceNode>(buildContext, listOf(inputs))
}
NavTarget.Root -> {
val callback = object : SpaceNode.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) {
callback.onOpenRoom(roomId, viaParameters)
}
override fun onLeaveSpace() {
backstack.push(NavTarget.Leave)
}
}
createNode<SpaceNode>(buildContext, listOf(inputs, callback))
}
}
}
@Composable
override fun View(modifier: Modifier) = BackstackView()
}

View file

@ -0,0 +1,18 @@
/*
* Copyright 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.features.space.impl.leave
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface LeaveSpaceEvents {
data object SelectAllRooms : LeaveSpaceEvents
data object DeselectAllRooms : LeaveSpaceEvents
data class ToggleRoomSelection(val roomId: RoomId) : LeaveSpaceEvents
data object LeaveSpace : LeaveSpaceEvents
data object CloseError : LeaveSpaceEvents
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.leave
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -21,24 +21,20 @@ import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
class SpaceNode( class LeaveSpaceNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory, presenterFactory: LeaveSpacePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: SpaceEntryPoint.Inputs = inputs() private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<SpaceEntryPoint.Callback>().single()
private val presenter = presenterFactory.create(inputs) private val presenter = presenterFactory.create(inputs)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
SpaceView( LeaveSpaceView(
state = state, state = state,
onBackClick = ::navigateUp, onCancel = ::navigateUp,
onRoomClick = { spaceRoom ->
callback.onOpenRoom(spaceRoom.roomId, spaceRoom.via)
},
modifier = modifier modifier = modifier
) )
} }

View file

@ -0,0 +1,132 @@
/*
* Copyright 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.features.space.impl.leave
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.Inject
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.jvm.optionals.getOrNull
@Inject
class LeaveSpacePresenter(
@Assisted private val inputs: SpaceEntryPoint.Inputs,
matrixClient: MatrixClient,
) : Presenter<LeaveSpaceState> {
@AssistedFactory
fun interface Factory {
fun create(inputs: SpaceEntryPoint.Inputs): LeaveSpacePresenter
}
private val spaceRoomList = matrixClient.spaceService.spaceRoomList(inputs.roomId)
@Composable
override fun present(): LeaveSpaceState {
val coroutineScope = rememberCoroutineScope()
val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState()
val leaveSpaceAction = remember {
mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized)
}
val selectedRoomIds = remember {
mutableStateOf<ImmutableSet<RoomId>>(persistentSetOf())
}
val joinedSpaceRooms by produceState(emptyList()) {
// TODO Get the joined room from the SDK, should also have the isLastAdmin boolean
val rooms = emptyList<SpaceRoom>()
// By default select all rooms
selectedRoomIds.value = rooms.map { it.roomId }.toPersistentSet()
value = rooms
}
val selectableSpaceRooms by produceState<AsyncData<ImmutableList<SelectableSpaceRoom>>>(
initialValue = AsyncData.Uninitialized,
key1 = joinedSpaceRooms,
key2 = selectedRoomIds.value,
) {
value = AsyncData.Success(
joinedSpaceRooms.map {
SelectableSpaceRoom(
spaceRoom = it,
// TODO Get this value from the SDK
isLastAdmin = false,
isSelected = selectedRoomIds.value.contains(it.roomId),
)
}.toPersistentList()
)
}
fun handleEvents(event: LeaveSpaceEvents) {
when (event) {
LeaveSpaceEvents.DeselectAllRooms -> {
selectedRoomIds.value = persistentSetOf()
}
LeaveSpaceEvents.SelectAllRooms -> {
selectedRoomIds.value = selectableSpaceRooms.dataOrNull()
.orEmpty()
.filter { it.isLastAdmin.not() }
.map { it.spaceRoom.roomId }
.toPersistentSet()
}
is LeaveSpaceEvents.ToggleRoomSelection -> {
val currentSet = selectedRoomIds.value
selectedRoomIds.value = if (currentSet.contains(event.roomId)) {
currentSet - event.roomId
} else {
currentSet + event.roomId
}
.toPersistentSet()
}
LeaveSpaceEvents.LeaveSpace -> coroutineScope.leaveSpace(
leaveSpaceAction = leaveSpaceAction,
selectedRoomIds = selectedRoomIds.value,
)
LeaveSpaceEvents.CloseError -> {
leaveSpaceAction.value = AsyncAction.Uninitialized
}
}
}
return LeaveSpaceState(
spaceName = currentSpace.getOrNull()?.name,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction.value,
eventSink = ::handleEvents,
)
}
private fun CoroutineScope.leaveSpace(
leaveSpaceAction: MutableState<AsyncAction<Unit>>,
@Suppress("unused") selectedRoomIds: Set<RoomId>,
) = launch {
runUpdatingState(leaveSpaceAction) {
// TODO SDK API call to leave all the rooms and space
Result.failure(Exception("Not implemented"))
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 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.features.space.impl.leave
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
data class LeaveSpaceState(
val spaceName: String?,
val selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>>,
val leaveSpaceAction: AsyncAction<Unit>,
val eventSink: (LeaveSpaceEvents) -> Unit,
) {
private val rooms = selectableSpaceRooms.dataOrNull().orEmpty()
private val partition = rooms.partition { it.isLastAdmin }
private val lastAdminRooms = partition.first
private val selectableRooms = partition.second
/**
* True if we should show the quick action to select/deselect all rooms.
*/
val showQuickAction = selectableRooms.isNotEmpty()
/**
* True if there all the selectable rooms are selected.
*/
val areAllSelected = selectableRooms.all { it.isSelected }
/**
* True if there are rooms but the user is the last admin in all of them.
*/
val hasOnlyLastAdminRoom = lastAdminRooms.isNotEmpty() && selectableRooms.isEmpty()
/**
* Number of selected rooms.
*/
val selectedRoomsCount = selectableRooms.count { it.isSelected }
}

View file

@ -0,0 +1,130 @@
/*
* Copyright 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.features.space.impl.leave
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
class LeaveSpaceStateProvider : PreviewParameterProvider<LeaveSpaceState> {
override val values: Sequence<LeaveSpaceState>
get() = sequenceOf(
aLeaveSpaceState(),
aLeaveSpaceState(
spaceName = null,
selectableSpaceRooms = AsyncData.Success(persistentListOf()),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
name = "A long space name that should be truncated",
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = false,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
joinRule = JoinRule.Private,
),
isSelected = true,
),
)
)
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
)
),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf(
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(
worldReadable = true,
),
isLastAdmin = true,
),
aSelectableSpaceRoom(
spaceRoom = aSpaceRoom(),
isLastAdmin = true,
),
)
),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toPersistentList()
),
leaveSpaceAction = AsyncAction.Loading,
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
List(10) { aSelectableSpaceRoom() }.toPersistentList()
),
leaveSpaceAction = AsyncAction.Failure(Exception("An error")),
),
aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Failure(Exception("An error")),
),
)
}
fun aLeaveSpaceState(
spaceName: String? = "Space name",
selectableSpaceRooms: AsyncData<ImmutableList<SelectableSpaceRoom>> = AsyncData.Uninitialized,
leaveSpaceAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
) = LeaveSpaceState(
spaceName = spaceName,
selectableSpaceRooms = selectableSpaceRooms,
leaveSpaceAction = leaveSpaceAction,
eventSink = { }
)
fun aSelectableSpaceRoom(
spaceRoom: SpaceRoom = aSpaceRoom(),
isLastAdmin: Boolean = false,
isSelected: Boolean = false,
) = SelectableSpaceRoom(
spaceRoom = spaceRoom,
isLastAdmin = isLastAdmin,
isSelected = isSelected,
)

View file

@ -0,0 +1,346 @@
/*
* Copyright 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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.space.impl.leave
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.space.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
/**
* https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=3947-68767&t=GTf1cLkAf6UCQDan-0
*/
@Composable
fun LeaveSpaceView(
state: LeaveSpaceState,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = ElementTheme.colors.bgCanvasDefault,
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.imePadding()
.consumeWindowInsets(padding)
.fillMaxSize()
.padding(16.dp)
) {
LeaveSpaceHeader(
state = state,
onBackClick = onCancel,
)
LazyColumn(
modifier = Modifier
.weight(1f),
) {
when (state.selectableSpaceRooms) {
is AsyncData.Success -> {
// List rooms where the user is the only admin
state.selectableSpaceRooms.data.forEach { selectableSpaceRoom ->
item {
SpaceItem(
selectableSpaceRoom = selectableSpaceRoom,
showCheckBox = state.hasOnlyLastAdminRoom.not(),
onClick = {
state.eventSink(LeaveSpaceEvents.ToggleRoomSelection(selectableSpaceRoom.spaceRoom.roomId))
}
)
}
}
}
is AsyncData.Failure -> item {
AsyncFailure(
throwable = state.selectableSpaceRooms.error,
onRetry = null,
)
}
is AsyncData.Loading,
AsyncData.Uninitialized -> item {
AsyncLoading()
}
}
}
LeaveSpaceButtons(
showLeaveButton = state.selectableSpaceRooms is AsyncData.Success,
selectedRoomsCount = state.selectedRoomsCount,
onLeaveSpace = {
state.eventSink(LeaveSpaceEvents.LeaveSpace)
},
onCancel = onCancel,
)
}
}
AsyncActionView(
async = state.leaveSpaceAction,
onSuccess = { /* Nothing to do, the screen will be dismissed automatically */ },
onErrorDismiss = { state.eventSink(LeaveSpaceEvents.CloseError) },
)
}
@Composable
private fun LeaveSpaceHeader(
state: LeaveSpaceState,
onBackClick: () -> Unit,
) {
Column {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {},
)
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 0.dp, bottom = 8.dp, start = 24.dp, end = 24.dp),
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(
R.string.screen_leave_space_title,
state.spaceName ?: stringResource(CommonStrings.common_space)
),
subTitle =
if (state.selectableSpaceRooms is AsyncData.Success && state.selectableSpaceRooms.data.isNotEmpty()) {
val count = state.selectableSpaceRooms.data.size
if (state.hasOnlyLastAdminRoom) {
pluralStringResource(R.plurals.screen_leave_space_subtitle_only_last_admin, count, count)
} else {
stringResource(R.string.screen_leave_space_subtitle)
}
} else {
null
},
)
if (state.showQuickAction) {
if (state.areAllSelected) {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.DeselectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_deselect_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
} else {
Text(
modifier = Modifier
.align(Alignment.End)
.clickable {
state.eventSink(LeaveSpaceEvents.SelectAllRooms)
}
.padding(vertical = 8.dp, horizontal = 8.dp),
text = stringResource(CommonStrings.common_select_all),
color = ElementTheme.colors.textActionPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
}
}
}
}
@Composable
private fun LeaveSpaceButtons(
showLeaveButton: Boolean,
selectedRoomsCount: Int,
onLeaveSpace: () -> Unit,
onCancel: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(top = 16.dp)
) {
if (showLeaveButton) {
val text = if (selectedRoomsCount > 0) {
pluralStringResource(R.plurals.screen_leave_space_submit, selectedRoomsCount, selectedRoomsCount)
} else {
stringResource(CommonStrings.action_leave_space)
}
Button(
modifier = Modifier.fillMaxWidth(),
text = text,
leadingIcon = IconSource.Vector(CompoundIcons.Leave()),
onClick = onLeaveSpace,
destructive = true,
)
}
TextButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(CommonStrings.action_cancel),
onClick = onCancel,
)
}
}
@Composable
private fun SpaceItem(
selectableSpaceRoom: SelectableSpaceRoom,
showCheckBox: Boolean,
onClick: () -> Unit,
) {
val room = selectableSpaceRoom.spaceRoom
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 66.dp)
.toggleable(
value = selectableSpaceRoom.isSelected,
role = Role.Checkbox,
enabled = selectableSpaceRoom.isLastAdmin.not(),
onValueChange = { onClick() }
)
.clickable(
enabled = selectableSpaceRoom.isLastAdmin.not(),
// TODO
onClickLabel = null,
role = Role.Checkbox,
onClick = onClick,
),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier.padding(horizontal = 16.dp),
avatarData = room.getAvatarData(AvatarSize.LeaveSpaceRoom),
avatarType = if (room.isSpace) AvatarType.Space() else AvatarType.Room(),
)
Column(
modifier = Modifier.weight(1f),
) {
Text(
modifier = Modifier
.padding(end = 16.dp),
text = room.name ?: stringResource(
if (room.isSpace) {
CommonStrings.common_no_space_name
} else {
CommonStrings.common_no_room_name
},
),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (room.joinRule == JoinRule.Private) {
// Picto for private
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.LockSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
} else if (room.worldReadable) {
// Picto for world readable
Icon(
modifier = Modifier
.size(16.dp)
.padding(end = 4.dp),
imageVector = CompoundIcons.Public(),
contentDescription = null,
tint = ElementTheme.colors.iconTertiary,
)
}
// Number of members
val subTitle = buildString {
append(
pluralStringResource(
CommonPlurals.common_member_count,
room.numJoinedMembers,
room.numJoinedMembers
)
)
if (selectableSpaceRoom.isLastAdmin) {
append(" ")
append(stringResource(R.string.screen_leave_space_last_admin_info))
}
}
Text(
modifier = Modifier.padding(end = 16.dp),
text = subTitle,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (showCheckBox) {
Checkbox(
checked = selectableSpaceRoom.isSelected,
onCheckedChange = null,
enabled = selectableSpaceRoom.isLastAdmin.not(),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LeaveSpaceViewPreview(
@PreviewParameter(LeaveSpaceStateProvider::class) state: LeaveSpaceState,
) = ElementPreview {
LeaveSpaceView(
state = state,
onCancel = {},
)
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 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.features.space.impl.leave
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
data class SelectableSpaceRoom(
val spaceRoom: SpaceRoom,
val isLastAdmin: Boolean,
val isSelected: Boolean,
)

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
sealed interface SpaceEvents { sealed interface SpaceEvents {
data object LoadMore : SpaceEvents data object LoadMore : SpaceEvents

View file

@ -0,0 +1,85 @@
/*
* Copyright 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.features.space.impl.root
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesNode(SessionScope::class)
@AssistedInject
class SpaceNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SpacePresenter.Factory,
private val matrixClient: MatrixClient,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId, viaParameters: List<String>)
fun onLeaveSpace()
}
private val inputs: SpaceEntryPoint.Inputs = inputs()
private val callback = plugins.filterIsInstance<Callback>().single()
private val presenter = presenterFactory.create(inputs)
private fun onShareRoom(context: Context) = lifecycleScope.launch {
matrixClient.getRoom(inputs.roomId)?.use { room ->
room.getPermalink()
.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = context.getString(CommonStrings.common_share_space),
text = permalink,
noActivityFoundMessage = context.getString(R.string.error_no_compatible_app_found)
)
}
.onFailure {
Timber.e(it)
}
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val context = LocalContext.current
SpaceView(
state = state,
onBackClick = ::navigateUp,
onLeaveSpaceClick = {
callback.onLeaveSpace()
},
onRoomClick = { spaceRoom ->
callback.onOpenRoom(spaceRoom.roomId, spaceRoom.via)
},
onShareSpace = {
onShareRoom(context)
},
modifier = modifier
)
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
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

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -34,7 +34,7 @@ open class SpaceStateProvider : PreviewParameterProvider<SpaceState> {
aSpaceState( aSpaceState(
hasMoreToLoad = false, hasMoreToLoad = false,
children = aListOfSpaceRooms() children = aListOfSpaceRooms()
) ),
// Add other states here // Add other states here
) )
} }

View file

@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -17,7 +17,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -28,6 +31,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -36,6 +40,10 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
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.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
@ -51,13 +59,20 @@ import kotlinx.collections.immutable.toImmutableList
fun SpaceView( fun SpaceView(
state: SpaceState, state: SpaceState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
onRoomClick: (spaceRoom: SpaceRoom) -> Unit, onRoomClick: (spaceRoom: SpaceRoom) -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
SpaceViewTopBar(currentSpace = state.currentSpace, onBackClick = onBackClick) SpaceViewTopBar(
state = state,
onBackClick = onBackClick,
onLeaveSpaceClick = onLeaveSpaceClick,
onShareSpace = onShareSpace,
)
}, },
content = { padding -> content = { padding ->
Box( Box(
@ -140,10 +155,13 @@ private fun LoadingMoreIndicator(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun SpaceViewTopBar( private fun SpaceViewTopBar(
currentSpace: SpaceRoom?, state: SpaceState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
@Suppress("unused") onLeaveSpaceClick: () -> Unit,
onShareSpace: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val currentSpace = state.currentSpace
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
navigationIcon = { navigationIcon = {
@ -158,6 +176,51 @@ private fun SpaceViewTopBar(
} }
}, },
actions = { actions = {
var showMenu by remember { mutableStateOf(false) }
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = CompoundIcons.OverflowVertical(),
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onShareSpace()
},
text = { Text(stringResource(id = CommonStrings.action_share)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
/*
// TODO re-enable when we have SDK APIs to leave a space
DropdownMenuItem(
onClick = {
showMenu = false
onLeaveSpaceClick()
},
text = { Text(stringResource(id = CommonStrings.action_leave)) },
leadingIcon = {
Icon(
imageVector = CompoundIcons.Leave(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
}
)
*/
}
}, },
) )
} }
@ -198,7 +261,9 @@ internal fun SpaceViewPreview(
) = ElementPreview { ) = ElementPreview {
SpaceView( SpaceView(
state = state, state = state,
onRoomClick = {},
onBackClick = {}, onBackClick = {},
onLeaveSpaceClick = {},
onRoomClick = {},
onShareSpace = {},
) )
} }

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_leave_space_last_admin_info">"(Admin)"</string>
<plurals name="screen_leave_space_submit">
<item quantity="one">"Leave %1$d room and space"</item>
<item quantity="other">"Leave %1$d rooms and space"</item>
</plurals>
<string name="screen_leave_space_subtitle">"Select the rooms youd like to leave which you\'re not the only administrator for:"</string>
<plurals name="screen_leave_space_subtitle_only_last_admin">
<item quantity="one">"You will not be removed from the following room because you\'re the only administrator:"</item>
<item quantity="other">"You will not be removed from the following rooms because you\'re the only administrator:"</item>
</plurals>
<string name="screen_leave_space_title">"Leave %1$s?"</string>
</resources>

View file

@ -9,14 +9,11 @@ package io.element.android.features.space.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule import org.junit.Rule
@ -26,38 +23,27 @@ class DefaultSpaceEntryPointTest {
@get:Rule @get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule() val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test @Test
fun `test node builder`() { fun `test node builder`() {
val entryPoint = DefaultSpaceEntryPoint() val entryPoint = DefaultSpaceEntryPoint()
val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID) val nodeInputs = SpaceEntryPoint.Inputs(A_ROOM_ID)
val parentNode = TestParentNode.create { buildContext, plugins -> val parentNode = TestParentNode.create { buildContext, plugins ->
SpaceNode( SpaceFlowNode(
buildContext = buildContext, buildContext = buildContext,
plugins = plugins, plugins = plugins,
presenterFactory = { inputs ->
assertThat(inputs).isEqualTo(nodeInputs)
SpacePresenter(
inputs = inputs,
client = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { FakeSpaceRoomList() },
)
),
seenInvitesStore = InMemorySeenInvitesStore(),
)
},
) )
} }
val callback = object : SpaceEntryPoint.Callback { val callback = object : SpaceEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) { override fun onOpenRoom(roomId: RoomId, viaParameters: List<String>) = lambdaError()
lambdaError()
}
} }
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.inputs(nodeInputs) .inputs(nodeInputs)
.callback(callback) .callback(callback)
.build() .build()
assertThat(result).isInstanceOf(SpaceNode::class.java) assertThat(result).isInstanceOf(SpaceFlowNode::class.java)
assertThat(result.plugins).contains(nodeInputs) assertThat(result.plugins).contains(nodeInputs)
assertThat(result.plugins).contains(callback) assertThat(result.plugins).contains(callback)
} }

View file

@ -0,0 +1,82 @@
/*
* Copyright 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList
import io.element.android.libraries.matrix.test.spaces.FakeSpaceService
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LeaveSpacePresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = {
FakeSpaceRoomList()
},
),
),
)
presenter.test {
val state = awaitItem()
assertThat(state.spaceName).isNull()
assertThat(state.selectableSpaceRooms).isEqualTo(AsyncData.Uninitialized)
assertThat(state.leaveSpaceAction).isEqualTo(AsyncAction.Uninitialized)
skipItems(1)
}
}
@Test
fun `present - current space name`() = runTest {
val fakeSpaceRoomList = FakeSpaceRoomList()
val presenter = createLeaveSpacePresenter(
matrixClient = FakeMatrixClient(
spaceService = FakeSpaceService(
spaceRoomListResult = { fakeSpaceRoomList },
),
),
)
presenter.test {
val state = awaitItem()
advanceUntilIdle()
assertThat(state.spaceName).isNull()
val aSpace = aSpaceRoom(
name = A_SPACE_NAME
)
fakeSpaceRoomList.emitCurrentSpace(aSpace)
skipItems(1)
assertThat(awaitItem().spaceName).isEqualTo(A_SPACE_NAME)
}
}
private fun createLeaveSpacePresenter(
inputs: SpaceEntryPoint.Inputs = SpaceEntryPoint.Inputs(A_ROOM_ID),
matrixClient: MatrixClient = FakeMatrixClient(),
): LeaveSpacePresenter {
return LeaveSpacePresenter(
inputs = inputs,
matrixClient = matrixClient,
)
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright 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.features.space.impl.leave
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import org.junit.Test
class LeaveSpaceStateTest {
@Test
fun `test loading`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Loading()
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no rooms`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
persistentListOf()
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
@Test
fun `test no last admin, 1 selected, 1 not selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = false),
).toPersistentList()
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.areAllSelected).isFalse()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(1)
}
@Test
fun `test no last admin, 2 selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
).toPersistentList()
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
}
@Test
fun `test 1 last admin, 2 selected`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
aSelectableSpaceRoom(isLastAdmin = false, isSelected = true),
).toPersistentList()
)
)
assertThat(sut.showQuickAction).isTrue()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isFalse()
assertThat(sut.selectedRoomsCount).isEqualTo(2)
}
@Test
fun `test only last admin`() {
val sut = aLeaveSpaceState(
selectableSpaceRooms = AsyncData.Success(
listOf(
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
aSelectableSpaceRoom(isLastAdmin = true, isSelected = false),
).toPersistentList()
)
)
assertThat(sut.showQuickAction).isFalse()
assertThat(sut.areAllSelected).isTrue()
assertThat(sut.hasOnlyLastAdminRoom).isTrue()
assertThat(sut.selectedRoomsCount).isEqualTo(0)
}
}

View file

@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class) @file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.space.impl package io.element.android.features.space.impl.root
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.SeenInvitesStore

View file

@ -72,6 +72,7 @@ enum class AvatarSize(val dp: Dp) {
RoomPreviewHeader(64.dp), RoomPreviewHeader(64.dp),
RoomPreviewInviter(56.dp), RoomPreviewInviter(56.dp),
SpaceMember(24.dp), SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
AccountItem(32.dp), AccountItem(32.dp),
} }

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.asSharedFlow
class RoomMembershipObserver { class RoomMembershipObserver {
data class RoomMembershipUpdate( data class RoomMembershipUpdate(
val roomId: RoomId, val roomId: RoomId,
val isSpace: Boolean,
val isUserInRoom: Boolean, val isUserInRoom: Boolean,
val change: MembershipChange, val change: MembershipChange,
) )
@ -22,12 +23,23 @@ class RoomMembershipObserver {
private val _updates = MutableSharedFlow<RoomMembershipUpdate>(extraBufferCapacity = 10) private val _updates = MutableSharedFlow<RoomMembershipUpdate>(extraBufferCapacity = 10)
val updates = _updates.asSharedFlow() val updates = _updates.asSharedFlow()
suspend fun notifyUserLeftRoom(roomId: RoomId, membershipBeforeLeft: CurrentUserMembership) { suspend fun notifyUserLeftRoom(
roomId: RoomId,
isSpace: Boolean,
membershipBeforeLeft: CurrentUserMembership,
) {
val membershipChange = when (membershipBeforeLeft) { val membershipChange = when (membershipBeforeLeft) {
CurrentUserMembership.INVITED -> MembershipChange.INVITATION_REJECTED CurrentUserMembership.INVITED -> MembershipChange.INVITATION_REJECTED
CurrentUserMembership.KNOCKED -> MembershipChange.KNOCK_RETRACTED CurrentUserMembership.KNOCKED -> MembershipChange.KNOCK_RETRACTED
else -> MembershipChange.LEFT else -> MembershipChange.LEFT
} }
_updates.emit(RoomMembershipUpdate(roomId, false, membershipChange)) _updates.emit(
RoomMembershipUpdate(
roomId = roomId,
isSpace = isSpace,
isUserInRoom = false,
change = membershipChange,
)
)
} }
} }

View file

@ -157,7 +157,11 @@ class RustBaseRoom(
runCatchingExceptions { runCatchingExceptions {
innerRoom.leave() innerRoom.leave()
}.onSuccess { }.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(roomId, membershipBeforeLeft) roomMembershipObserver.notifyUserLeftRoom(
roomId = roomId,
isSpace = roomInfoFlow.value.isSpace,
membershipBeforeLeft = membershipBeforeLeft,
)
} }
} }

View file

@ -57,6 +57,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.LEFT)
} }
@ -77,6 +78,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.KNOCK_RETRACTED)
} }
@ -97,6 +99,7 @@ class RustBaseRoomTest {
leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) { leaveRoomAndObserveMembershipChange(roomMembershipObserver, rustBaseRoom) {
val membershipUpdate = awaitItem() val membershipUpdate = awaitItem()
assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId) assertThat(membershipUpdate.roomId).isEqualTo(rustBaseRoom.roomId)
assertThat(membershipUpdate.isSpace).isFalse()
assertThat(membershipUpdate.isUserInRoom).isFalse() assertThat(membershipUpdate.isUserInRoom).isFalse()
assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED) assertThat(membershipUpdate.change).isEqualTo(MembershipChange.INVITATION_REJECTED)
} }

View file

@ -65,6 +65,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
const val A_CAPTION = "A media caption" const val A_CAPTION = "A media caption"
const val A_REASON = "A reason" const val A_REASON = "A reason"
const val A_SPACE_NAME = "A space name"
const val A_REDACTION_REASON = "A redaction reason" const val A_REDACTION_REASON = "A redaction reason"
const val A_HOMESERVER_URL = "matrix.org" const val A_HOMESERVER_URL = "matrix.org"

View file

@ -419,9 +419,6 @@ Opravdu chcete pokračovat?"</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string> <string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string> <string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Tím budete také odstraněni ze všech místností v tomto prostoru."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Tímto budete také odstraněni ze všech místností v tomto prostoru, včetně těch, jejichž jediným správcem jste:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Opustit %1$s?"</string>
<string name="screen_bug_report_a11y_screenshot">"Snímek obrazovky"</string> <string name="screen_bug_report_a11y_screenshot">"Snímek obrazovky"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Možnosti"</string> <string name="screen_create_poll_options_section_title">"Možnosti"</string>

View file

@ -445,9 +445,6 @@ Ydych chi\'n siŵr eich bod am barhau?"</string>
<string name="invite_friends_text">"Hei, siaradwch â mi ar %1$s: %2$s"</string> <string name="invite_friends_text">"Hei, siaradwch â mi ar %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"Android %1$s"</string> <string name="login_initial_device_name_android">"Android %1$s"</string>
<string name="preference_rageshake">"Rageshake i adrodd gwall"</string> <string name="preference_rageshake">"Rageshake i adrodd gwall"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Bydd hyn hefyd yn eich tynnu o bob ystafell yn y gofod hwn."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Bydd hyn hefyd yn eich tynnu o bob ystafell yn y gofod hwn, gan gynnwys y rhai rydych chi\'n unig weinyddwr ar eu cyfer:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Gadael %1$s ?"</string>
<string name="screen_bug_report_a11y_screenshot">"Llun sgrin"</string> <string name="screen_bug_report_a11y_screenshot">"Llun sgrin"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Dewisiadau"</string> <string name="screen_create_poll_options_section_title">"Dewisiadau"</string>

View file

@ -412,9 +412,6 @@ Möchtest du wirklich fortfahren?"</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string> <string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string> <string name="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Dadurch wirst du auch aus allen Chats in diesem Space entfernt."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Dadurch wirst du auch aus allen Chats in diesem Space entfernt, auch aus denen, für die du der einzige Admin bist:"</string>
<string name="screen_bottom_sheet_leave_space_title">"%1$s verlassen?"</string>
<string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string> <string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Optionen"</string> <string name="screen_create_poll_options_section_title">"Optionen"</string>

View file

@ -411,9 +411,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string> <string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string> <string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Sellega eemaldad end ka kõikidest antud kogukonna jututubadest."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Sellega eemaldad end ka kõikidest antud kogukonna jututubadest, sealhulgast järgnevaist, kus oled ainus peakasutaja:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Kas lahkud %1$s kogukonnast?"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string> <string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string> <string name="screen_create_poll_options_section_title">"Valikud"</string>

View file

@ -413,9 +413,6 @@ Haluatko varmasti jatkaa?"</string>
<string name="invite_friends_text">"Hei, keskustele kanssani %1$s -sovelluksessa: %2$s"</string> <string name="invite_friends_text">"Hei, keskustele kanssani %1$s -sovelluksessa: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Raivostunut ravistaminen ilmoittaa virheestä"</string> <string name="preference_rageshake">"Raivostunut ravistaminen ilmoittaa virheestä"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"Tämä poistaa sinut myös kaikista tämän tilan huoneista."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"Tämä poistaa sinut myös kaikista tämän tilan huoneista, mukaan lukien ne, joissa olet ainoa ylläpitäjä:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Haluatko poistua tilasta %1$s?"</string>
<string name="screen_bug_report_a11y_screenshot">"Näyttökuva"</string> <string name="screen_bug_report_a11y_screenshot">"Näyttökuva"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Vaihtoehdot"</string> <string name="screen_create_poll_options_section_title">"Vaihtoehdot"</string>

View file

@ -404,9 +404,6 @@
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string> <string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"憤怒搖晃以回報臭蟲"</string> <string name="preference_rageshake">"憤怒搖晃以回報臭蟲"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"這也會將您從此空間中的所有聊天室移除。"</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"這也會將您從此空間中的所有聊天室移除,包含您是唯一管理員的聊天室:"</string>
<string name="screen_bottom_sheet_leave_space_title">"離開 %1$s"</string>
<string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string> <string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s%2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s%2$s"</string>
<string name="screen_create_poll_options_section_title">"選項"</string> <string name="screen_create_poll_options_section_title">"選項"</string>

View file

@ -190,6 +190,7 @@
<string name="common_dark">"Dark"</string> <string name="common_dark">"Dark"</string>
<string name="common_decryption_error">"Decryption error"</string> <string name="common_decryption_error">"Decryption error"</string>
<string name="common_description">"Description"</string> <string name="common_description">"Description"</string>
<string name="common_deselect_all">"Deselect all"</string>
<string name="common_developer_options">"Developer options"</string> <string name="common_developer_options">"Developer options"</string>
<string name="common_device_id">"Device ID"</string> <string name="common_device_id">"Device ID"</string>
<string name="common_direct_chat">"Direct chat"</string> <string name="common_direct_chat">"Direct chat"</string>
@ -301,6 +302,7 @@ Reason: %1$s."</string>
<string name="common_security">"Security"</string> <string name="common_security">"Security"</string>
<string name="common_seen_by">"Seen by"</string> <string name="common_seen_by">"Seen by"</string>
<string name="common_select_account">"Select an account"</string> <string name="common_select_account">"Select an account"</string>
<string name="common_select_all">"Select all"</string>
<string name="common_send_to">"Send to"</string> <string name="common_send_to">"Send to"</string>
<string name="common_sending">"Sending…"</string> <string name="common_sending">"Sending…"</string>
<string name="common_sending_failed">"Sending failed"</string> <string name="common_sending_failed">"Sending failed"</string>
@ -414,9 +416,6 @@ Are you sure you want to continue?"</string>
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string> <string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string> <string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string> <string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bottom_sheet_leave_space_subtitle">"This will also remove you from all rooms in this space."</string>
<string name="screen_bottom_sheet_leave_space_subtitle_admin">"This will also remove you from all rooms in this space, including those youre the only administrator for:"</string>
<string name="screen_bottom_sheet_leave_space_title">"Leave %1$s?"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string> <string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string> <string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
@ -460,6 +459,7 @@ Are you sure you want to continue?"</string>
<string name="screen_share_this_location_action">"Share this location"</string> <string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_space_list_description">"Spaces you have created or joined."</string> <string name="screen_space_list_description">"Spaces you have created or joined."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string> <string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_parent_space">"%1$s space"</string>
<string name="screen_space_list_title">"Spaces"</string> <string name="screen_space_list_title">"Spaces"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$ss verified identity was reset."</string> <string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$ss verified identity was reset."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string> <string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ab6848052b8bb306bbe8bbd87a62599047f7e530b70a17417b7ba51f2d90ecf5 oid sha256:157bbbc4511b29b531d85f465c473b7ac186af5f697dfd6ffb6da5a02bb0ca02
size 14278 size 16960

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ddb1b57c91c8d3adec9b79307d52a36ac4516e7020f2c3eafc9343fd9d9e368 oid sha256:cf17a6c4cd39061bd34e616ec10440d47826a6af81033b99c3a1fb1060e95563
size 13536 size 16237

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f80fd46fb18b92e462e079647b42f6cb8cd101f900120d4927d3101defe2dd36 oid sha256:992430d8297f30891aeaf61b054660fc4a4d1c125b06f7df4acc860beda79611
size 16213 size 18890

View file

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

View file

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

View file

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

View file

@ -200,6 +200,12 @@
"screen\\.security_and_privacy\\..*" "screen\\.security_and_privacy\\..*"
] ]
}, },
{
"name" : ":features:space:impl",
"includeRegex" : [
"screen\\.leave_space\\..*"
]
},
{ {
"name" : ":features:userprofile:shared", "name" : ":features:userprofile:shared",
"includeRegex" : [ "includeRegex" : [